/*
* Copyright 2019 The Android Open Source Project
*
* 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 androidx.navigation.safe.args.generator.java
import androidx.navigation.safe.args.generator.BoolArrayType
import androidx.navigation.safe.args.generator.BoolType
import androidx.navigation.safe.args.generator.FloatArrayType
import androidx.navigation.safe.args.generator.FloatType
import androidx.navigation.safe.args.generator.IntArrayType
import androidx.navigation.safe.args.generator.IntType
import androidx.navigation.safe.args.generator.LongArrayType
import androidx.navigation.safe.args.generator.LongType
import androidx.navigation.safe.args.generator.NavWriter
import androidx.navigation.safe.args.generator.ObjectArrayType
import androidx.navigation.safe.args.generator.ObjectType
import androidx.navigation.safe.args.generator.ReferenceArrayType
import androidx.navigation.safe.args.generator.ReferenceType
import androidx.navigation.safe.args.generator.StringArrayType
import androidx.navigation.safe.args.generator.StringType
import androidx.navigation.safe.args.generator.ext.capitalize
import androidx.navigation.safe.args.generator.ext.toCamelCase
import androidx.navigation.safe.args.generator.ext.toCamelCaseAsVar
import androidx.navigation.safe.args.generator.models.Action
import androidx.navigation.safe.args.generator.models.Argument
import androidx.navigation.safe.args.generator.models.Destination
import com.squareup.javapoet.AnnotationSpec
import com.squareup.javapoet.ClassName
import com.squareup.javapoet.CodeBlock
import com.squareup.javapoet.FieldSpec
import com.squareup.javapoet.JavaFile
import com.squareup.javapoet.MethodSpec
import com.squareup.javapoet.ParameterSpec
import com.squareup.javapoet.TypeName
import com.squareup.javapoet.TypeSpec
import java.util.Locale
import javax.lang.model.element.Modifier
const val L = "\$L"
const val N = "\$N"
const val T = "\$T"
const val S = "\$S"
const val BEGIN_STMT = "\$["
const val END_STMT = "\$]"
class JavaNavWriter(private val useAndroidX: Boolean = true) : NavWriter<JavaCodeFile> {
override fun generateDirectionsCodeFile(
destination: Destination,
parentDirectionsFileList: List<JavaCodeFile>
): JavaCodeFile {
val className = destination.toClassName()
val typeSpec =
generateDestinationDirectionsTypeSpec(className, destination, parentDirectionsFileList)
return JavaFile.builder(className.packageName(), typeSpec).build().toCodeFile()
}
private fun generateDestinationDirectionsTypeSpec(
className: ClassName,
destination: Destination,
parentDirectionsFileList: List<JavaCodeFile>
): TypeSpec {
val constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()
val actionTypes = destination.actions.map { action ->
action to generateDirectionsTypeSpec(action)
}
@Suppress("NAME_SHADOWING")
val getters = actionTypes
.map { (action, actionType) ->
val annotations = Annotations.getInstance(useAndroidX)
val methodName = action.id.javaIdentifier.toCamelCaseAsVar()
if (action.args.isEmpty()) {
MethodSpec.methodBuilder(methodName)
.addAnnotation(annotations.NONNULL_CLASSNAME)
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(NAV_DIRECTION_CLASSNAME)
.addStatement(
"return new $T($L)",
ACTION_ONLY_NAV_DIRECTION_CLASSNAME, action.id.accessor()
)
.build()
} else {
val constructor = actionType.methodSpecs.find(MethodSpec::isConstructor)!!
val params = constructor.parameters.joinToString(", ") { param -> param.name }
val actionTypeName = ClassName.get(
className.packageName(),
className.simpleName(),
actionType.name
)
MethodSpec.methodBuilder(methodName)
.addAnnotation(annotations.NONNULL_CLASSNAME)
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.addParameters(constructor.parameters)
.returns(actionTypeName)
.addStatement("return new $T($params)", actionTypeName)
.build()
}
}
// The parent destination list is ordered from the closest to the farthest parent of the
// processing destination in the graph hierarchy.
val parentGetters = mutableListOf<MethodSpec>()
parentDirectionsFileList.forEach {
val parentPackageName = it.wrapped.packageName
val parentTypeSpec = it.wrapped.typeSpec
parentTypeSpec.methodSpecs.filter { method ->
method.hasModifier(Modifier.STATIC) &&
getters.none { it.name == method.name } && // de-dupe local actions
parentGetters.none { it.name == method.name } // de-dupe parent actions
}.forEach { actionMethod ->
val params = actionMethod.parameters.joinToString(", ") { param -> param.name }
val methodSpec = MethodSpec.methodBuilder(actionMethod.name)
.addAnnotations(actionMethod.annotations)
.addModifiers(actionMethod.modifiers)
.addParameters(actionMethod.parameters)
.returns(actionMethod.returnType)
.addStatement(
"return $T.$L($params)",
ClassName.get(parentPackageName, parentTypeSpec.name), actionMethod.name
)
.build()
parentGetters.add(methodSpec)
}
}
return TypeSpec.classBuilder(className)
.addModifiers(Modifier.PUBLIC)
.addTypes(
actionTypes
.filter { (action, _) -> action.args.isNotEmpty() }
.map { (_, actionType) -> actionType }
)
.addMethod(constructor)
.addMethods(getters + parentGetters)
.build()
}
internal fun generateDirectionsTypeSpec(action: Action): TypeSpec {
val annotations = Annotations.getInstance(useAndroidX)
val specs = ClassWithArgsSpecs(action.args, annotations, privateConstructor = true)
val className = ClassName.get("", action.id.javaIdentifier.toCamelCase())
val getDestIdMethod = MethodSpec.methodBuilder("getActionId")
.addAnnotation(Override::class.java)
.addModifiers(Modifier.PUBLIC)
.returns(Int::class.java)
.addStatement("return $L", action.id.accessor())
.build()
val additionalEqualsBlock = CodeBlock.builder().apply {
beginControlFlow("if ($N() != that.$N())", getDestIdMethod, getDestIdMethod).apply {
addStatement("return false")
}
endControlFlow()
}.build()
val additionalHashCodeBlock = CodeBlock.builder().apply {
addStatement("result = 31 * result + $N()", getDestIdMethod)
}.build()
val toStringHeaderBlock = CodeBlock.builder().apply {
add("$S + $L() + $S", "${className.simpleName()}(actionId=", getDestIdMethod.name, "){")
}.build()
return TypeSpec.classBuilder(className)
.addSuperinterface(NAV_DIRECTION_CLASSNAME)
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.addField(specs.hashMapFieldSpec)
.addMethod(specs.constructor())
.addMethods(specs.setters(className))
.addMethod(specs.toBundleMethod("getArguments", true))
.addMethod(getDestIdMethod)
.addMethods(specs.getters())
.addMethod(specs.equalsMethod(className, additionalEqualsBlock))
.addMethod(specs.hashCodeMethod(additionalHashCodeBlock))
.addMethod(specs.toStringMethod(className, toStringHeaderBlock))
.build()
}
override fun generateArgsCodeFile(
destination: Destination
): JavaCodeFile {
val annotations = Annotations.getInstance(useAndroidX)
val destName = destination.name
?: throw IllegalStateException("Destination with arguments must have name")
val className = ClassName.get(destName.packageName(), "${destName.simpleName()}Args")
val args = destination.args
val specs = ClassWithArgsSpecs(args, annotations)
val fromBundleMethod = MethodSpec.methodBuilder("fromBundle").apply {
addAnnotation(annotations.NONNULL_CLASSNAME)
addModifiers(Modifier.PUBLIC, Modifier.STATIC)
addAnnotation(specs.suppressAnnotationSpec)
val bundle = "bundle"
addParameter(
ParameterSpec.builder(BUNDLE_CLASSNAME, bundle)
.addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
.build()
)
returns(className)
val result = "__result"
addStatement("$T $N = new $T()", className, result, className)
addStatement("$N.setClassLoader($T.class.getClassLoader())", bundle, className)
args.forEach { arg ->
addReadSingleArgBlock("containsKey", bundle, result, arg, specs) {
arg.type.addBundleGetStatement(this, arg, arg.sanitizedName, bundle)
}
}
addStatement("return $N", result)
}.build()
val fromSavedStateHandleMethod = MethodSpec.methodBuilder("fromSavedStateHandle").apply {
addAnnotation(annotations.NONNULL_CLASSNAME)
addModifiers(Modifier.PUBLIC, Modifier.STATIC)
addAnnotation(specs.suppressAnnotationSpec)
val savedStateHandle = "savedStateHandle"
addParameter(
ParameterSpec.builder(SAVED_STATE_HANDLE_CLASSNAME, savedStateHandle)
.addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
.build()
)
returns(className)
val result = "__result"
addStatement("$T $N = new $T()", className, result, className)
args.forEach { arg ->
addReadSingleArgBlock("contains", savedStateHandle, result, arg, specs) {
addStatement("$N = $N.get($S)", arg.sanitizedName, savedStateHandle, arg.name)
}
}
addStatement("return $N", result)
}.build()
val constructor = MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()
val copyConstructor = MethodSpec.constructorBuilder()
.addAnnotation(specs.suppressAnnotationSpec)
.addModifiers(Modifier.PUBLIC)
.addParameter(
ParameterSpec.builder(className, "original")
.addAnnotation(specs.androidAnnotations.NONNULL_CLASSNAME)
.build()
)
.addCode(specs.copyMapContents("this", "original"))
.build()
val fromMapConstructor = MethodSpec.constructorBuilder()
.addAnnotation(specs.suppressAnnotationSpec)
.addModifiers(Modifier.PRIVATE)
.addParameter(HASHMAP_CLASSNAME, "argumentsMap")
.addStatement(
"$N.$N.putAll($N)",
"this",
specs.hashMapFieldSpec.name,
"argumentsMap"
)
.build()
val buildMethod = MethodSpec.methodBuilder("build")
.addAnnotation(annotations.NONNULL_CLASSNAME)
.addModifiers(Modifier.PUBLIC)
.returns(className)
.addStatement(
"$T result = new $T($N)",
className,
className,
specs.hashMapFieldSpec.name
)
.addStatement("return result")
.build()
val builderClassName = ClassName.get("", "Builder")
val builderTypeSpec = TypeSpec.classBuilder("Builder")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)
.addField(specs.hashMapFieldSpec)
.addMethod(copyConstructor)
.addMethod(specs.constructor())
.addMethod(buildMethod)
.addMethods(specs.setters(builderClassName))
.addMethods(specs.getters(true))
.build()
val typeSpec = TypeSpec.classBuilder(className)
.addSuperinterface(NAV_ARGS_CLASSNAME)
.addModifiers(Modifier.PUBLIC)
.addField(specs.hashMapFieldSpec)
.addMethod(constructor)
.addMethod(fromMapConstructor)
.addMethod(fromBundleMethod)
.addMethod(fromSavedStateHandleMethod)
.addMethods(specs.getters())
.addMethod(specs.toBundleMethod("toBundle"))
.addMethod(specs.toSavedStateHandleMethod())
.addMethod(specs.equalsMethod(className))
.addMethod(specs.hashCodeMethod())
.addMethod(specs.toStringMethod(className))
.addType(builderTypeSpec)
.build()
return JavaFile.builder(className.packageName(), typeSpec).build().toCodeFile()
}
private fun MethodSpec.Builder.addReadSingleArgBlock(
containsMethodName: String,
sourceVariableName: String,
targetVariableName: String,
arg: Argument,
specs: ClassWithArgsSpecs,
addGetStatement: MethodSpec.Builder.() -> Unit
) {
beginControlFlow("if ($N.$containsMethodName($S))", sourceVariableName, arg.name)
addStatement("$T $N", arg.type.typeName(), arg.sanitizedName)
addGetStatement()
addNullCheck(arg, arg.sanitizedName)
addStatement(
"$targetVariableName.$N.put($S, $N)",
specs.hashMapFieldSpec,
arg.name,
arg.sanitizedName
)
nextControlFlow("else")
if (arg.defaultValue == null) {
addStatement(
"throw new $T($S)", IllegalArgumentException::class.java,
"Required argument \"${arg.name}\" is missing and does not have an " +
"android:defaultValue"
)
} else {
addStatement(
"$targetVariableName.$N.put($S, $L)",
specs.hashMapFieldSpec,
arg.name,
arg.defaultValue.write()
)
}
endControlFlow()
}
}
private class ClassWithArgsSpecs(
val args: List<Argument>,
val androidAnnotations: Annotations,
val privateConstructor: Boolean = false
) {
val suppressAnnotationSpec = AnnotationSpec.builder(SuppressWarnings::class.java)
.addMember("value", "$S", "unchecked")
.build()
val hashMapFieldSpec = FieldSpec.builder(
HASHMAP_CLASSNAME,
"arguments",
Modifier.PRIVATE,
Modifier.FINAL
).initializer("new $T()", HASHMAP_CLASSNAME).build()
fun setters(thisClassName: ClassName) = args.map { arg ->
val capitalizedName = arg.sanitizedName.capitalize(Locale.US)
MethodSpec.methodBuilder("set$capitalizedName").apply {
addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
addAnnotation(suppressAnnotationSpec)
addModifiers(Modifier.PUBLIC)
addParameter(generateParameterSpec(arg))
addNullCheck(arg, arg.sanitizedName)
addStatement(
"this.$N.put($S, $N)",
hashMapFieldSpec.name,
arg.name,
arg.sanitizedName
)
addStatement("return this")
returns(thisClassName)
}.build()
}
fun constructor() = MethodSpec.constructorBuilder().apply {
if (args.filterNot(Argument::isOptional).isNotEmpty()) {
addAnnotation(suppressAnnotationSpec)
}
addModifiers(if (privateConstructor) Modifier.PRIVATE else Modifier.PUBLIC)
args.filterNot(Argument::isOptional).forEach { arg ->
addParameter(generateParameterSpec(arg))
addNullCheck(arg, arg.sanitizedName)
addStatement(
"this.$N.put($S, $N)",
hashMapFieldSpec.name,
arg.name,
arg.sanitizedName
)
}
}.build()
fun toBundleMethod(
name: String,
addOverrideAnnotation: Boolean = false
) = MethodSpec.methodBuilder(name).apply {
if (addOverrideAnnotation) {
addAnnotation(Override::class.java)
}
addAnnotation(suppressAnnotationSpec)
addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
addModifiers(Modifier.PUBLIC)
returns(BUNDLE_CLASSNAME)
val result = "__result"
addStatement("$T $N = new $T()", BUNDLE_CLASSNAME, result, BUNDLE_CLASSNAME)
args.forEach { arg ->
beginControlFlow("if ($N.containsKey($S))", hashMapFieldSpec.name, arg.name).apply {
addStatement(
"$T $N = ($T) $N.get($S)",
arg.type.typeName(),
arg.sanitizedName,
arg.type.typeName(),
hashMapFieldSpec.name,
arg.name
)
arg.type.addBundlePutStatement(this, arg, result, arg.sanitizedName)
}
if (arg.defaultValue != null) {
nextControlFlow("else").apply {
arg.type.addBundlePutStatement(this, arg, result, arg.defaultValue.write())
}
}
endControlFlow()
}
addStatement("return $N", result)
}.build()
fun toSavedStateHandleMethod(
addOverrideAnnotation: Boolean = false
) = MethodSpec.methodBuilder("toSavedStateHandle").apply {
if (addOverrideAnnotation) {
addAnnotation(Override::class.java)
}
addAnnotation(suppressAnnotationSpec)
addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
addModifiers(Modifier.PUBLIC)
returns(SAVED_STATE_HANDLE_CLASSNAME)
val result = "__result"
addStatement(
"$T $N = new $T()", SAVED_STATE_HANDLE_CLASSNAME, result, SAVED_STATE_HANDLE_CLASSNAME
)
args.forEach { arg ->
beginControlFlow("if ($N.containsKey($S))", hashMapFieldSpec.name, arg.name).apply {
addStatement(
"$T $N = ($T) $N.get($S)",
arg.type.typeName(),
arg.sanitizedName,
arg.type.typeName(),
hashMapFieldSpec.name,
arg.name
)
arg.type.addSavedStateHandleSetStatement(this, arg, result, arg.sanitizedName)
}
if (arg.defaultValue != null) {
nextControlFlow("else").apply {
arg.type.addSavedStateHandleSetStatement(
this, arg, result, arg.defaultValue.write()
)
}
}
endControlFlow()
}
addStatement("return $N", result)
}.build()
fun copyMapContents(to: String, from: String) = CodeBlock.builder()
.addStatement(
"$N.$N.putAll($N.$N)",
to,
hashMapFieldSpec.name,
from,
hashMapFieldSpec.name
).build()
fun getters(isBuilder: Boolean = false) = args.map { arg ->
MethodSpec.methodBuilder(getterFromArgName(arg.sanitizedName)).apply {
addModifiers(Modifier.PUBLIC)
if (!isBuilder) {
addAnnotation(suppressAnnotationSpec)
} else {
addAnnotation(
AnnotationSpec.builder(SuppressWarnings::class.java)
.addMember("value", "{$S,$S}", "unchecked", "GetterOnBuilder")
.build()
)
}
if (arg.type.allowsNullable()) {
if (arg.isNullable) {
addAnnotation(androidAnnotations.NULLABLE_CLASSNAME)
} else {
addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
}
}
addStatement(
"return ($T) $N.get($S)",
arg.type.typeName(),
hashMapFieldSpec.name,
arg.name
)
returns(arg.type.typeName())
}.build()
}
fun equalsMethod(
className: ClassName,
additionalCode: CodeBlock? = null
) = MethodSpec.methodBuilder("equals").apply {
addAnnotation(Override::class.java)
addModifiers(Modifier.PUBLIC)
addParameter(TypeName.OBJECT, "object")
addCode(
"""
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
""".trimIndent()
)
addStatement("$T that = ($T) object", className, className)
args.forEach { (name, type, _, _, sanitizedName) ->
beginControlFlow(
"if ($N.containsKey($S) != that.$N.containsKey($S))",
hashMapFieldSpec,
name,
hashMapFieldSpec,
name
).apply {
addStatement("return false")
}.endControlFlow()
val getterName = getterFromArgName(sanitizedName, "()")
val compareExpression = when (type) {
IntType,
BoolType,
ReferenceType,
LongType -> "$getterName != that.$getterName"
FloatType -> "Float.compare(that.$getterName, $getterName) != 0"
StringType, IntArrayType, LongArrayType, FloatArrayType, StringArrayType,
BoolArrayType, ReferenceArrayType, is ObjectArrayType, is ObjectType ->
"$getterName != null ? !$getterName.equals(that.$getterName) " +
": that.$getterName != null"
else -> throw IllegalStateException("unknown type: $type")
}
beginControlFlow("if ($N)", compareExpression).apply {
addStatement("return false")
}
endControlFlow()
}
if (additionalCode != null) {
addCode(additionalCode)
}
addStatement("return true")
returns(TypeName.BOOLEAN)
}.build()
private fun getterFromArgName(sanitizedName: String, suffix: String = ""): String {
val capitalizedName = sanitizedName.capitalize(Locale.US)
return "get${capitalizedName}$suffix"
}
fun hashCodeMethod(
additionalCode: CodeBlock? = null
) = MethodSpec.methodBuilder("hashCode").apply {
addAnnotation(Override::class.java)
addModifiers(Modifier.PUBLIC)
addStatement("int result = 1")
args.forEach { (_, type, _, _, sanitizedName) ->
val getterName = getterFromArgName(sanitizedName, "()")
val hashCodeExpression = when (type) {
IntType, ReferenceType -> getterName
FloatType -> "Float.floatToIntBits($getterName)"
IntArrayType, LongArrayType, FloatArrayType, StringArrayType,
BoolArrayType, ReferenceArrayType, is ObjectArrayType ->
"java.util.Arrays.hashCode($getterName)"
StringType, is ObjectType ->
"($getterName != null ? $getterName.hashCode() : 0)"
BoolType -> "($getterName ? 1 : 0)"
LongType -> "(int)($getterName ^ ($getterName >>> 32))"
else -> throw IllegalStateException("unknown type: $type")
}
addStatement("result = 31 * result + $N", hashCodeExpression)
}
if (additionalCode != null) {
addCode(additionalCode)
}
addStatement("return result")
returns(TypeName.INT)
}.build()
fun toStringMethod(
className: ClassName,
toStringHeaderBlock: CodeBlock? = null
) = MethodSpec.methodBuilder("toString").apply {
addAnnotation(Override::class.java)
addModifiers(Modifier.PUBLIC)
addCode(
CodeBlock.builder().apply {
if (toStringHeaderBlock != null) {
add("${BEGIN_STMT}return $L", toStringHeaderBlock)
} else {
add("${BEGIN_STMT}return $S", "${className.simpleName()}{")
}
args.forEachIndexed { index, (_, _, _, _, sanitizedName) ->
val getterName = getterFromArgName(sanitizedName, "()")
val prefix = if (index == 0) "" else ", "
add("\n+ $S + $L", "$prefix$sanitizedName=", getterName)
}
add("\n+ $S;\n$END_STMT", "}")
}.build()
)
returns(ClassName.get(String::class.java))
}.build()
private fun generateParameterSpec(arg: Argument): ParameterSpec {
return ParameterSpec.builder(arg.type.typeName(), arg.sanitizedName).apply {
if (arg.type.allowsNullable()) {
if (arg.isNullable) {
addAnnotation(androidAnnotations.NULLABLE_CLASSNAME)
} else {
addAnnotation(androidAnnotations.NONNULL_CLASSNAME)
}
}
}.build()
}
}
internal fun MethodSpec.Builder.addNullCheck(
arg: Argument,
variableName: String
) {
if (arg.type.allowsNullable() && !arg.isNullable) {
beginControlFlow("if ($N == null)", variableName).apply {
addStatement(
"throw new $T($S)", IllegalArgumentException::class.java,
"Argument \"${arg.name}\" is marked as non-null but was passed a null value."
)
}
endControlFlow()
}
}
internal fun Destination.toClassName(): ClassName {
val destName = name ?: throw IllegalStateException("Destination with actions must have name")
return ClassName.get(destName.packageName(), "${destName.simpleName()}Directions")
}