Sanitize argument names to avoid generating invalid safe-args classes.

This change allows for argument names to have some non-valid java field
name, such as dots or bangs while still generating correct safe-args
classes. The names are stripped out of non-alphabetical and non-numeric
characters, then they are camel-cased to generate Java/Kotlin consistent
names. Note that this strategy is not a silver bullet that will
perfectly sanitize any string, but it is nicer than doing nothing.

Moreover, an error is now thrown when multiple different non-sanitized
argument names result in the same string after being sanitized.

Finally, this change also applies camel-case to actions class and method
names generated. Specifically if the id uses the underscore convention
such as R.id.open_details, then the generated class and method will be
camel-cased to OpenDetails (class) and openDetails (method).

Bug: 79995048
Bug: 79642240
Test: ./gradlew :navigation:navigation-safe-args-generator:test
Change-Id: Ie81ebf660c90663510ff33e6a0d045d2a2ed1261
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParser.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParser.kt
index a21846b..ad4b0af 100644
--- a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParser.kt
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParser.kt
@@ -16,6 +16,7 @@
 
 package androidx.navigation.safe.args.generator
 
+import androidx.navigation.safe.args.generator.NavParserErrors.sameSanitizedNameArguments
 import androidx.navigation.safe.args.generator.models.Action
 import androidx.navigation.safe.args.generator.models.Argument
 import androidx.navigation.safe.args.generator.models.Destination
@@ -74,6 +75,12 @@
             }
         }
 
+        args.groupBy { it.sanitizedName }.forEach { (sanitizedName, args) ->
+            if (args.size > 1) {
+                context.logger.error(sameSanitizedNameArguments(sanitizedName, args), position)
+            }
+        }
+
         val id = idValue?.let { parseId(idValue, rFilePackage, position) }
         val className = Destination.createName(id, name, applicationId)
         if (className == null && (actions.isNotEmpty() || args.isNotEmpty())) {
@@ -139,6 +146,12 @@
             }
         }
 
+        args.groupBy { it.sanitizedName }.forEach { (sanitizedName, args) ->
+            if (args.size > 1) {
+                context.logger.error(sameSanitizedNameArguments(sanitizedName, args), position)
+            }
+        }
+
         val id = if (idValue != null) {
             parseId(idValue, rFilePackage, position)
         } else {
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParserErrors.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParserErrors.kt
index 611c955..150032c 100644
--- a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParserErrors.kt
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavParserErrors.kt
@@ -16,6 +16,8 @@
 
 package androidx.navigation.safe.args.generator
 
+import androidx.navigation.safe.args.generator.models.Argument
+
 object NavParserErrors {
     val UNNAMED_DESTINATION = "Destination with arguments or actions must have " +
         "'name' or 'id' attributes."
@@ -30,4 +32,9 @@
         " @[+][package:]id/resource_name "
 
     fun unknownType(type: String?) = "Unknown type '$type'"
+
+    fun sameSanitizedNameArguments(sanitizedName: String, args: List<Argument>) =
+            "Multiple same name arguments. The named arguments: " +
+            "[${args.joinToString(", ") { it.name }}] result in the generator using " +
+                    "the same name: '$sanitizedName'."
 }
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavWriter.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavWriter.kt
index d613f9b..398fbaa 100644
--- a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavWriter.kt
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/NavWriter.kt
@@ -19,6 +19,8 @@
 import androidx.navigation.safe.args.generator.ext.N
 import androidx.navigation.safe.args.generator.ext.S
 import androidx.navigation.safe.args.generator.ext.T
+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
@@ -39,7 +41,7 @@
 private class ClassWithArgsSpecs(val args: List<Argument>) {
 
     fun fieldSpecs() = args.map { arg ->
-        FieldSpec.builder(arg.type.typeName(), arg.name)
+        FieldSpec.builder(arg.type.typeName(), arg.sanitizedName)
                 .apply {
                     addModifiers(Modifier.PRIVATE)
                     if (arg.isOptional()) {
@@ -49,11 +51,11 @@
                 .build()
     }
 
-    fun setters(thisClassName: ClassName) = args.map { (name, type) ->
-        MethodSpec.methodBuilder("set${name.capitalize()}")
+    fun setters(thisClassName: ClassName) = args.map { (_, type, _, sanitizedName) ->
+        MethodSpec.methodBuilder("set${sanitizedName.capitalize()}")
                 .addModifiers(Modifier.PUBLIC)
-                .addParameter(type.typeName(), name)
-                .addStatement("this.$N = $N", name, name)
+                .addParameter(type.typeName(), sanitizedName)
+                .addStatement("this.$N = $N", sanitizedName, sanitizedName)
                 .addStatement("return this")
                 .returns(thisClassName)
                 .build()
@@ -61,9 +63,9 @@
 
     fun constructor() = MethodSpec.constructorBuilder().apply {
         addModifiers(Modifier.PUBLIC)
-        args.filterNot(Argument::isOptional).forEach { (argName, type) ->
-            addParameter(type.typeName(), argName)
-            addStatement("this.$N = $N", argName, argName)
+        args.filterNot(Argument::isOptional).forEach { (_, type, _, sanitizedName) ->
+            addParameter(type.typeName(), sanitizedName)
+            addStatement("this.$N = $N", sanitizedName, sanitizedName)
         }
     }.build()
 
@@ -72,23 +74,26 @@
         returns(BUNDLE_CLASSNAME)
         val bundleName = "__outBundle"
         addStatement("$T $N = new $T()", BUNDLE_CLASSNAME, bundleName, BUNDLE_CLASSNAME)
-        args.forEach { (argName, type) ->
-            addStatement("$N.$N($S, this.$N)", bundleName, type.bundlePutMethod(), argName, argName)
+        args.forEach { (name, type, _, sanitizedName) ->
+            addStatement("$N.$N($S, this.$N)", bundleName, type.bundlePutMethod(), name,
+                    sanitizedName)
         }
         addStatement("return $N", bundleName)
     }.build()
 
     fun copyProperties(to: String, from: String) = CodeBlock.builder()
             .apply {
-                args.forEach { arg -> addStatement("$to.${arg.name} = $from.${arg.name}") }
+                args.forEach { (_, _, _, sanitizedName) ->
+                    addStatement("$to.$sanitizedName = $from.$sanitizedName")
+                }
             }
             .build()
 
-    fun getters() = args.map { arg ->
-        MethodSpec.methodBuilder("get${arg.name.capitalize()}")
+    fun getters() = args.map { (_, type, _, sanitizedName) ->
+        MethodSpec.methodBuilder("get${sanitizedName.capitalize()}")
                 .addModifiers(Modifier.PUBLIC)
-                .addStatement("return $N", arg.name)
-                .returns(arg.type.typeName())
+                .addStatement("return $N", sanitizedName)
+                .returns(type.typeName())
                 .build()
     }
 
@@ -109,13 +114,13 @@
 
                 """.trimIndent())
         addStatement("${className.simpleName()} that = (${className.simpleName()}) object")
-        args.forEach {
-            val compareExpression = when (it.type) {
+        args.forEach { (_, type, _, sanitizedName) ->
+            val compareExpression = when (type) {
                 NavType.INT, NavType.BOOLEAN, NavType.REFERENCE, NavType.LONG ->
-                    "${it.name} != that.${it.name}"
-                NavType.FLOAT -> "Float.compare(that.${it.name}, ${it.name}) != 0"
-                NavType.STRING -> "${it.name} != null ? !${it.name}.equals(that.${it.name}) " +
-                        ": that.${it.name} != null"
+                    "$sanitizedName != that.$sanitizedName"
+                NavType.FLOAT -> "Float.compare(that.$sanitizedName, $sanitizedName) != 0"
+                NavType.STRING -> "$sanitizedName != null ? " +
+                        "!$sanitizedName.equals(that.$sanitizedName) : that.$sanitizedName != null"
             }
             beginControlFlow("if ($N)", compareExpression).apply {
                 addStatement("return false")
@@ -130,13 +135,13 @@
         addAnnotation(Override::class.java)
         addModifiers(Modifier.PUBLIC)
         addStatement("int result = super.hashCode()")
-        args.forEach {
-            val hashCodeExpression = when (it.type) {
-                NavType.INT, NavType.REFERENCE -> it.name
-                NavType.FLOAT -> "Float.floatToIntBits(${it.name})"
-                NavType.STRING -> "(${it.name} != null ? ${it.name}.hashCode() : 0)"
-                NavType.BOOLEAN -> "(${it.name} ? 1 : 0)"
-                NavType.LONG -> "(int)(${it.name} ^ (${it.name} >>> 32))"
+        args.forEach { (_, type, _, sanitizedName) ->
+            val hashCodeExpression = when (type) {
+                NavType.INT, NavType.REFERENCE -> sanitizedName
+                NavType.FLOAT -> "Float.floatToIntBits($sanitizedName)"
+                NavType.STRING -> "($sanitizedName != null ? $sanitizedName.hashCode() : 0)"
+                NavType.BOOLEAN -> "($sanitizedName ? 1 : 0)"
+                NavType.LONG -> "(int)($sanitizedName ^ ($sanitizedName >>> 32))"
             }
             addStatement("result = 31 * result + $N", hashCodeExpression)
         }
@@ -158,7 +163,7 @@
                 val constructor = actionType.methodSpecs.find(MethodSpec::isConstructor)!!
                 val params = constructor.parameters.joinToString(", ") { param -> param.name }
                 val actionTypeName = ClassName.get("", actionType.name)
-                MethodSpec.methodBuilder(action.id.name)
+                MethodSpec.methodBuilder(action.id.name.toCamelCaseAsVar())
                         .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
                         .addParameters(constructor.parameters)
                         .returns(actionTypeName)
@@ -182,7 +187,7 @@
             .addStatement("return $N", action.id.accessor())
             .build()
 
-    val className = ClassName.get("", action.id.name.capitalize())
+    val className = ClassName.get("", action.id.name.toCamelCase())
     return TypeSpec.classBuilder(className)
             .addSuperinterface(NAV_DIRECTION_CLASSNAME)
             .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
@@ -210,7 +215,7 @@
         addStatement("$T $N = new $T()", className, result, className)
         args.forEach { arg ->
             beginControlFlow("if ($N.containsKey($S))", bundle, arg.name).apply {
-                addStatement("$N.$N = $N.$N($S)", result, arg.name, bundle,
+                addStatement("$N.$N = $N.$N($S)", result, arg.sanitizedName, bundle,
                         arg.type.bundleGetMethod(), arg.name)
             }
             if (!arg.isOptional()) {
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/List_ext.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/List_ext.kt
new file mode 100644
index 0000000..2c19c7f
--- /dev/null
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/List_ext.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2018 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.ext
+
+fun List<String>.joinToCamelCase(): String = when (size) {
+    0 -> throw IllegalArgumentException("invalid section size, cannot be zero")
+    1 -> this[0].toCamelCase()
+    else -> this.joinToString("") { it.toCamelCase() }
+}
+
+fun List<String>.joinToCamelCaseAsVar(): String = when (size) {
+    0 -> throw IllegalArgumentException("invalid section size, cannot be zero")
+    1 -> this[0].toCamelCaseAsVar()
+    else -> get(0).toCamelCaseAsVar() + drop(1).joinToCamelCase()
+}
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/String_ext.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/String_ext.kt
new file mode 100644
index 0000000..e3c0387
--- /dev/null
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/ext/String_ext.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2018 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.ext
+
+fun String.toCamelCase(): String {
+    val split = this.split("_")
+    if (split.size == 0) return ""
+    if (split.size == 1) return split[0].capitalize()
+    return split.joinToCamelCase()
+}
+
+fun String.toCamelCaseAsVar(): String {
+    val split = this.split("_")
+    if (split.size == 0) return ""
+    if (split.size == 1) return split[0]
+    return split.joinToCamelCaseAsVar()
+}
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/models/Argument.kt b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/models/Argument.kt
index fea1bdb..1dd1321 100644
--- a/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/models/Argument.kt
+++ b/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/models/Argument.kt
@@ -18,8 +18,14 @@
 
 import androidx.navigation.safe.args.generator.NavType
 import androidx.navigation.safe.args.generator.WriteableValue
+import androidx.navigation.safe.args.generator.ext.joinToCamelCaseAsVar
 
 data class Argument(val name: String, val type: NavType, val defaultValue: WriteableValue? = null) {
-    fun isOptional() = defaultValue != null
-}
 
+    val sanitizedName = name.split("[^a-zA-Z0-9]".toRegex())
+            .map { it.trim() }.joinToCamelCaseAsVar()
+
+    fun isOptional() = defaultValue != null
+
+    operator fun component4() = sanitizedName
+}
diff --git a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/InvalidXmlTest.kt b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/InvalidXmlTest.kt
index 639a24985..d30594e 100644
--- a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/InvalidXmlTest.kt
+++ b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/InvalidXmlTest.kt
@@ -20,6 +20,8 @@
 import androidx.navigation.safe.args.generator.NavParserErrors.invalidDefaultValue
 import androidx.navigation.safe.args.generator.NavParserErrors.invalidDefaultValueReference
 import androidx.navigation.safe.args.generator.NavParserErrors.invalidId
+import androidx.navigation.safe.args.generator.NavParserErrors.sameSanitizedNameArguments
+import androidx.navigation.safe.args.generator.models.Argument
 import org.hamcrest.CoreMatchers.`is`
 import org.hamcrest.MatcherAssert.assertThat
 import org.junit.Test
@@ -39,7 +41,9 @@
                 invalidDefaultValue("101034f", NavType.INT)),
             ErrorMessage("invalid_id_action.xml", 22, 44, invalidId("@+fppid/finish")),
             ErrorMessage("invalid_id_destination.xml", 17, 1, invalidId("@1234234+id/foo")),
-            ErrorMessage("action_no_id.xml", 22, 5, mandatoryAttrMissingError("action", "id"))
+            ErrorMessage("action_no_id.xml", 22, 5, mandatoryAttrMissingError("action", "id")),
+            ErrorMessage("same_name_args.xml", 23, 9, sameSanitizedNameArguments("myArg", listOf(
+                    Argument("my_arg", NavType.STRING), Argument("my.arg", NavType.STRING))))
         )
     }
 
diff --git a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavParserTest.kt b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavParserTest.kt
index 64b127d..5f7188e 100644
--- a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavParserTest.kt
+++ b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavParserTest.kt
@@ -30,6 +30,7 @@
 import org.hamcrest.CoreMatchers.`is`
 import org.hamcrest.CoreMatchers.nullValue
 import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -127,4 +128,30 @@
         assertThat(infer("123L"), `is`(longArg("123L")))
         assertThat(infer("1234123412341234L"), `is`(longArg("1234123412341234L")))
     }
+
+    @Test
+    fun testArgSanitizedName() {
+        assertEquals("camelCaseName",
+                Argument("camelCaseName", INT).sanitizedName)
+        assertEquals("ALLCAPSNAME",
+                Argument("ALLCAPSNAME", INT).sanitizedName)
+        assertEquals("alllowercasename",
+                Argument("alllowercasename", INT).sanitizedName)
+        assertEquals("nameWithUnderscore",
+                Argument("name_with_underscore", INT).sanitizedName)
+        assertEquals("NameWithUnderscore",
+                Argument("Name_With_Underscore", INT).sanitizedName)
+        assertEquals("NAMEWITHUNDERSCORE",
+                Argument("NAME_WITH_UNDERSCORE", INT).sanitizedName)
+        assertEquals("nameWithSpaces",
+                Argument("name with spaces", INT).sanitizedName)
+        assertEquals("nameWithDot",
+                Argument("name.with.dot", INT).sanitizedName)
+        assertEquals("nameWithDollars",
+                Argument("name\$with\$dollars", INT).sanitizedName)
+        assertEquals("nameWithBangs",
+                Argument("name!with!bangs", INT).sanitizedName)
+        assertEquals("nameWithHyphens",
+                Argument("name-with-hyphens", INT).sanitizedName)
+    }
 }
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavWriterTest.kt b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavWriterTest.kt
index b18ae5f..199ac62 100644
--- a/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavWriterTest.kt
+++ b/navigation/safe-args-generator/src/tests/kotlin/androidx/navigation/safe/args/generator/NavWriterTest.kt
@@ -43,7 +43,7 @@
 import javax.tools.JavaFileObject
 
 @RunWith(JUnit4::class)
-class WriterTest {
+class NavWriterTest {
 
     @get:Rule
     @Suppress("MemberVisibilityCanBePrivate")
@@ -126,6 +126,26 @@
     }
 
     @Test
+    fun testDirectionsClassGeneration_sanitizedNames() {
+        val nextAction = Action(id("next_action"), id("destA"),
+                listOf(
+                        Argument("main_arg", STRING),
+                        Argument("optional.arg", STRING, StringValue("bla"))))
+
+        val prevAction = Action(id("previous_action"), id("destB"),
+                listOf(
+                        Argument("arg_1", STRING),
+                        Argument("arg.2", STRING)))
+
+        val dest = Destination(null, ClassName.get("a.b", "SanitizedMainFragment"),
+                "fragment", listOf(), listOf(prevAction, nextAction))
+
+        val actual = toJavaFileObject(generateDirectionsJavaFile(dest))
+        JavaSourcesSubject.assertThat(actual).parsesAs("a.b.SanitizedMainFragmentDirections")
+        assertCompilesWithoutError(actual)
+    }
+
+    @Test
     fun testArgumentsClassGeneration() {
         val dest = Destination(null, ClassName.get("a.b", "MainFragment"), "fragment", listOf(
                 Argument("main", STRING),
@@ -140,4 +160,18 @@
         JavaSourcesSubject.assertThat(actual).parsesAs("a.b.MainFragmentArgs")
         assertCompilesWithoutError(actual)
     }
+
+    @Test
+    fun testArgumentsClassGeneration_sanitizedNames() {
+        val dest = Destination(null, ClassName.get("a.b", "SanitizedMainFragment"),
+                "fragment", listOf(
+                Argument("name.with.dot", INT),
+                Argument("name_with_underscore", INT),
+                Argument("name with spaces", INT)),
+                listOf())
+
+        val actual = toJavaFileObject(generateArgsJavaFile(dest))
+        JavaSourcesSubject.assertThat(actual).parsesAs("a.b.SanitizedMainFragmentArgs")
+        assertCompilesWithoutError(actual)
+    }
 }
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/tests/test-data/a/b/R.java b/navigation/safe-args-generator/src/tests/test-data/a/b/R.java
index 8d2fc90..815b7ae 100644
--- a/navigation/safe-args-generator/src/tests/test-data/a/b/R.java
+++ b/navigation/safe-args-generator/src/tests/test-data/a/b/R.java
@@ -23,6 +23,8 @@
         public static final int finish = 0x7f060000;
         public static final int previous = 0x7f060001;
         public static final int next = 0x7f060002;
+        public static final int previous_action = 0x7f060003;
+        public static final int next_action = 0x7f060004;
     }
 
     public static final class drawable {
diff --git a/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentArgs.java b/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentArgs.java
new file mode 100644
index 0000000..1dfa138
--- /dev/null
+++ b/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentArgs.java
@@ -0,0 +1,145 @@
+package a.b;
+
+import android.os.Bundle;
+import java.lang.IllegalArgumentException;
+import java.lang.Object;
+import java.lang.Override;
+
+public class SanitizedMainFragmentArgs {
+    private int nameWithDot;
+
+    private int nameWithUnderscore;
+
+    private int nameWithSpaces;
+
+    private SanitizedMainFragmentArgs() {
+    }
+
+    public static SanitizedMainFragmentArgs fromBundle(Bundle bundle) {
+        SanitizedMainFragmentArgs result = new SanitizedMainFragmentArgs();
+        if (bundle.containsKey("name.with.dot")) {
+            result.nameWithDot = bundle.getInt("name.with.dot");
+        } else {
+            throw new IllegalArgumentException("Required argument \"name.with.dot\" is missing and does not have an android:defaultValue");
+        }
+        if (bundle.containsKey("name_with_underscore")) {
+            result.nameWithUnderscore = bundle.getInt("name_with_underscore");
+        } else {
+            throw new IllegalArgumentException("Required argument \"name_with_underscore\" is missing and does not have an android:defaultValue");
+        }
+        if (bundle.containsKey("name with spaces")) {
+            result.nameWithSpaces = bundle.getInt("name with spaces");
+        } else {
+            throw new IllegalArgumentException("Required argument \"name with spaces\" is missing and does not have an android:defaultValue");
+        }
+        return result;
+    }
+
+    public int getNameWithDot() {
+        return nameWithDot;
+    }
+
+    public int getNameWithUnderscore() {
+        return nameWithUnderscore;
+    }
+
+    public int getNameWithSpaces() {
+        return nameWithSpaces;
+    }
+
+    public Bundle toBundle() {
+        Bundle __outBundle = new Bundle();
+        __outBundle.putInt("name.with.dot", this.nameWithDot);
+        __outBundle.putInt("name_with_underscore", this.nameWithUnderscore);
+        __outBundle.putInt("name with spaces", this.nameWithSpaces);
+        return __outBundle;
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (this == object) {
+            return true;
+        }
+        if (object == null || getClass() != object.getClass()) {
+            return false;
+        }
+        if (!super.equals(object)) {
+            return false;
+        }
+        SanitizedMainFragmentArgs that = (SanitizedMainFragmentArgs) object;
+        if (nameWithDot != that.nameWithDot) {
+            return false;
+        }
+        if (nameWithUnderscore != that.nameWithUnderscore) {
+            return false;
+        }
+        if (nameWithSpaces != that.nameWithSpaces) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = super.hashCode();
+        result = 31 * result + nameWithDot;
+        result = 31 * result + nameWithUnderscore;
+        result = 31 * result + nameWithSpaces;
+        return result;
+    }
+
+    public static class Builder {
+        private int nameWithDot;
+
+        private int nameWithUnderscore;
+
+        private int nameWithSpaces;
+
+        public Builder(SanitizedMainFragmentArgs original) {
+            this.nameWithDot = original.nameWithDot;
+            this.nameWithUnderscore = original.nameWithUnderscore;
+            this.nameWithSpaces = original.nameWithSpaces;
+        }
+
+        public Builder(int nameWithDot, int nameWithUnderscore, int nameWithSpaces) {
+            this.nameWithDot = nameWithDot;
+            this.nameWithUnderscore = nameWithUnderscore;
+            this.nameWithSpaces = nameWithSpaces;
+        }
+
+        public SanitizedMainFragmentArgs build() {
+            SanitizedMainFragmentArgs result = new SanitizedMainFragmentArgs();
+            result.nameWithDot = this.nameWithDot;
+            result.nameWithUnderscore = this.nameWithUnderscore;
+            result.nameWithSpaces = this.nameWithSpaces;
+            return result;
+        }
+
+        public Builder setNameWithDot(int nameWithDot) {
+            this.nameWithDot = nameWithDot;
+            return this;
+        }
+
+        public Builder setNameWithUnderscore(int nameWithUnderscore) {
+            this.nameWithUnderscore = nameWithUnderscore;
+            return this;
+        }
+
+        public Builder setNameWithSpaces(int nameWithSpaces) {
+            this.nameWithSpaces = nameWithSpaces;
+            return this;
+        }
+
+        public int getNameWithDot() {
+            return nameWithDot;
+        }
+
+        public int getNameWithUnderscore() {
+            return nameWithUnderscore;
+        }
+
+        public int getNameWithSpaces() {
+            return nameWithSpaces;
+        }
+    }
+}
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentDirections.java b/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentDirections.java
new file mode 100644
index 0000000..97ef33c
--- /dev/null
+++ b/navigation/safe-args-generator/src/tests/test-data/expected/SanitizedMainFragmentDirections.java
@@ -0,0 +1,78 @@
+package a.b;
+
+import android.os.Bundle;
+import androidx.navigation.NavDirections;
+import java.lang.String;
+
+public class SanitizedMainFragmentDirections {
+    public static PreviousAction previousAction(String arg1, String arg2) {
+        return new PreviousAction(arg1, arg2);
+    }
+
+    public static NextAction nextAction(String mainArg) {
+        return new NextAction(mainArg);
+    }
+
+    public static class PreviousAction implements NavDirections {
+        private String arg1;
+
+        private String arg2;
+
+        public PreviousAction(String arg1, String arg2) {
+            this.arg1 = arg1;
+            this.arg2 = arg2;
+        }
+
+        public PreviousAction setArg1(String arg1) {
+            this.arg1 = arg1;
+            return this;
+        }
+
+        public PreviousAction setArg2(String arg2) {
+            this.arg2 = arg2;
+            return this;
+        }
+
+        public Bundle getArguments() {
+            Bundle __outBundle = new Bundle();
+            __outBundle.putString("arg_1", this.arg1);
+            __outBundle.putString("arg.2", this.arg2);
+            return __outBundle;
+        }
+
+        public int getActionId() {
+            return a.b.R.id.previous_action;
+        }
+    }
+
+    public static class NextAction implements NavDirections {
+        private String mainArg;
+
+        private String optionalArg = "bla";
+
+        public NextAction(String mainArg) {
+            this.mainArg = mainArg;
+        }
+
+        public NextAction setMainArg(String mainArg) {
+            this.mainArg = mainArg;
+            return this;
+        }
+
+        public NextAction setOptionalArg(String optionalArg) {
+            this.optionalArg = optionalArg;
+            return this;
+        }
+
+        public Bundle getArguments() {
+            Bundle __outBundle = new Bundle();
+            __outBundle.putString("main_arg", this.mainArg);
+            __outBundle.putString("optional.arg", this.optionalArg);
+            return __outBundle;
+        }
+
+        public int getActionId() {
+            return a.b.R.id.next_action;
+        }
+    }
+}
\ No newline at end of file
diff --git a/navigation/safe-args-generator/src/tests/test-data/invalid_xmls/same_name_args.xml b/navigation/safe-args-generator/src/tests/test-data/invalid_xmls/same_name_args.xml
new file mode 100644
index 0000000..af99cd9b
--- /dev/null
+++ b/navigation/safe-args-generator/src/tests/test-data/invalid_xmls/same_name_args.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright 2018 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.
+  -->
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+            xmlns:app="http://schemas.android.com/apk/res-auto"
+            xmlns:tools="http://schemas.android.com/tools"
+            android:id="@+id/foo"
+            app:startDestination="@+id/first_screen">
+    <fragment android:name="a.FakeFragment">
+        <action android:id="@+id/finish" app:popUpTo="@id/first_screen">
+            <argument android:name="my_arg" app:type="string" />
+            <argument android:name="my.arg" app:type="string" />
+        </action>
+    </fragment>
+</navigation>
\ No newline at end of file