[go: nahoru, domu]

Merge "Room Gradle Plugin" into androidx-main
diff --git a/room/integration-tests/incremental-annotation-processing/src/test/kotlin/androidx/room/gradle/RoomIncrementalAnnotationProcessingTest.kt b/room/integration-tests/incremental-annotation-processing/src/test/kotlin/androidx/room/gradle/RoomIncrementalAnnotationProcessingTest.kt
index 8720b6e..7d9085e 100644
--- a/room/integration-tests/incremental-annotation-processing/src/test/kotlin/androidx/room/gradle/RoomIncrementalAnnotationProcessingTest.kt
+++ b/room/integration-tests/incremental-annotation-processing/src/test/kotlin/androidx/room/gradle/RoomIncrementalAnnotationProcessingTest.kt
@@ -18,6 +18,8 @@
 
 import androidx.testutils.gradle.ProjectSetupRule
 import com.google.common.truth.Expect
+import java.io.File
+import java.nio.file.Files
 import org.gradle.testkit.runner.BuildResult
 import org.gradle.testkit.runner.GradleRunner
 import org.gradle.testkit.runner.TaskOutcome
@@ -26,11 +28,6 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
-import java.io.File
-import java.nio.file.Files
-import javax.xml.parsers.DocumentBuilderFactory
-import javax.xml.xpath.XPathConstants
-import javax.xml.xpath.XPathFactory
 
 @RunWith(Parameterized::class)
 class RoomIncrementalAnnotationProcessingTest(
@@ -110,27 +107,7 @@
      * prebuilts (SNAPSHOT).
      */
     private val roomVersion by lazy {
-        val metadataFile = File(projectSetup.props.tipOfTreeMavenRepoPath).resolve(
-            "androidx/room/room-compiler/maven-metadata.xml"
-        )
-        check(metadataFile.exists()) {
-            "Cannot find room metadata file in ${metadataFile.absolutePath}"
-        }
-        check(metadataFile.isFile) {
-            "Metadata file should be a file but it is not."
-        }
-        val xmlDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
-            .parse(metadataFile)
-        val latestVersionNode = XPathFactory.newInstance().newXPath()
-            .compile("/metadata/versioning/latest").evaluate(
-                xmlDoc, XPathConstants.STRING
-            )
-        check(latestVersionNode is String) {
-            """Unexpected node for latest version:
-                $latestVersionNode / ${latestVersionNode::class.java}
-            """.trimIndent()
-        }
-        latestVersionNode
+        projectSetup.getLibraryLatestVersionInLocalRepo("androidx/room/room-compiler")
     }
 
     @Before
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/DatabaseProcessingStep.kt b/room/room-compiler/src/main/kotlin/androidx/room/DatabaseProcessingStep.kt
index 472f46e..62a4e01 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/DatabaseProcessingStep.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/DatabaseProcessingStep.kt
@@ -107,6 +107,7 @@
                 val filename = "${db.version}.json"
                 val exportToResources =
                     Context.BooleanProcessorOptions.EXPORT_SCHEMA_RESOURCE.getValue(env)
+                val schemaInFolderPath = context.schemaInFolderPath
                 val schemaOutFolderPath = context.schemaOutFolderPath
                 if (exportToResources) {
                     context.logger.w(ProcessorErrors.EXPORTING_SCHEMA_TO_RESOURCES)
@@ -115,19 +116,24 @@
                         originatingElements = listOf(db.element)
                     )
                     db.exportSchema(schemaFileOutputStream)
-                } else if (schemaOutFolderPath != null) {
+                } else if (schemaInFolderPath != null && schemaOutFolderPath != null) {
+                    val schemaInFolder = SchemaFileResolver.RESOLVER.getFile(
+                        Path.of(schemaInFolderPath)
+                    )
                     val schemaOutFolder = SchemaFileResolver.RESOLVER.getFile(
                         Path.of(schemaOutFolderPath)
                     )
                     if (!schemaOutFolder.exists()) {
                         schemaOutFolder.mkdirs()
                     }
-                    val dbSchemaFolder = File(schemaOutFolder, qName)
-                    if (!dbSchemaFolder.exists()) {
-                        dbSchemaFolder.mkdirs()
+                    val dbSchemaInFolder = File(schemaInFolder, qName)
+                    val dbSchemaOutFolder = File(schemaOutFolder, qName)
+                    if (!dbSchemaOutFolder.exists()) {
+                        dbSchemaOutFolder.mkdirs()
                     }
                     db.exportSchema(
-                        File(dbSchemaFolder, "${db.version}.json")
+                        inputFile = File(dbSchemaInFolder, "${db.version}.json"),
+                        outputFile = File(dbSchemaOutFolder, "${db.version}.json")
                     )
                 } else {
                     context.logger.w(
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
index 07ee499..54969bf 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
@@ -147,10 +147,32 @@
         }
     }
 
+    val schemaInFolderPath by lazy {
+        val internalInputFolder =
+            processingEnv.options[ProcessorOptions.INTERNAL_SCHEMA_INPUT_FOLDER.argName]
+        val legacySchemaFolder =
+            processingEnv.options[ProcessorOptions.OPTION_SCHEMA_FOLDER.argName]
+        if (!internalInputFolder.isNullOrBlank()) {
+            internalInputFolder
+        } else if (!legacySchemaFolder.isNullOrBlank()) {
+            legacySchemaFolder
+        } else {
+            null
+        }
+    }
+
     val schemaOutFolderPath by lazy {
-        val arg = processingEnv.options[ProcessorOptions.OPTION_SCHEMA_FOLDER.argName]
-        if (arg?.isNotEmpty() == true) {
-            arg
+        val internalOutputFolder =
+            processingEnv.options[ProcessorOptions.INTERNAL_SCHEMA_OUTPUT_FOLDER.argName]
+        val legacySchemaFolder =
+            processingEnv.options[ProcessorOptions.OPTION_SCHEMA_FOLDER.argName]
+        if (!internalOutputFolder.isNullOrBlank() && !legacySchemaFolder.isNullOrBlank()) {
+            logger.e(ProcessorErrors.INVALID_GRADLE_PLUGIN_AND_SCHEMA_LOCATION_OPTION)
+        }
+        if (!internalOutputFolder.isNullOrBlank()) {
+            internalOutputFolder
+        } else if (!legacySchemaFolder.isNullOrBlank()) {
+            legacySchemaFolder
         } else {
             null
         }
@@ -256,7 +278,9 @@
     }
 
     enum class ProcessorOptions(val argName: String) {
-        OPTION_SCHEMA_FOLDER("room.schemaLocation")
+        OPTION_SCHEMA_FOLDER("room.schemaLocation"),
+        INTERNAL_SCHEMA_INPUT_FOLDER("room.internal.schemaInput"),
+        INTERNAL_SCHEMA_OUTPUT_FOLDER("room.internal.schemaOutput"),
     }
 
     enum class BooleanProcessorOptions(val argName: String, private val defaultValue: Boolean) {
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/DatabaseProcessor.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/DatabaseProcessor.kt
index 6e045f2..211cd37 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/DatabaseProcessor.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/DatabaseProcessor.kt
@@ -28,7 +28,7 @@
 import androidx.room.migration.bundle.DatabaseBundle
 import androidx.room.migration.bundle.SchemaBundle
 import androidx.room.processor.ProcessorErrors.AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF
-import androidx.room.processor.ProcessorErrors.AUTO_MIGRATION_SCHEMA_OUT_FOLDER_NULL
+import androidx.room.processor.ProcessorErrors.AUTO_MIGRATION_SCHEMA_IN_FOLDER_NULL
 import androidx.room.processor.ProcessorErrors.autoMigrationSchemasMustBeRoomGenerated
 import androidx.room.processor.ProcessorErrors.invalidAutoMigrationSchema
 import androidx.room.util.SchemaFileResolver
@@ -151,63 +151,65 @@
 
         val autoMigrationList = dbAnnotation
             .getAsAnnotationBoxArray<AutoMigration>("autoMigrations")
+        if (autoMigrationList.isEmpty()) {
+            return emptyList()
+        }
 
-        if (autoMigrationList.isNotEmpty()) {
-            if (!dbAnnotation.value.exportSchema) {
-                context.logger.e(
-                    element,
-                    AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF
-                )
-                return emptyList()
-            }
-            if (context.schemaOutFolderPath == null) {
-                context.logger.e(
-                    element,
-                    AUTO_MIGRATION_SCHEMA_OUT_FOLDER_NULL
-                )
-                return emptyList()
-            }
+        if (!dbAnnotation.value.exportSchema) {
+            context.logger.e(
+                element,
+                AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF
+            )
+            return emptyList()
+        }
+        val schemaInFolderPath = context.schemaInFolderPath
+        if (schemaInFolderPath == null) {
+            context.logger.e(
+                element,
+                AUTO_MIGRATION_SCHEMA_IN_FOLDER_NULL
+            )
+            return emptyList()
         }
 
         return autoMigrationList.mapNotNull {
-            val databaseSchemaFolderPath = Path.of(
-                context.schemaOutFolderPath!!,
+            val databaseSchemaInFolderPath = Path.of(
+                schemaInFolderPath,
                 element.asClassName().canonicalName
             )
             val autoMigration = it.value
             val validatedFromSchemaFile = getValidatedSchemaFile(
                 autoMigration.from,
-                databaseSchemaFolderPath
-            )
+                databaseSchemaInFolderPath
+            ) ?: return@mapNotNull null
 
-            fun deserializeSchemaFile(fileInputStream: FileInputStream, versionNumber: Int): Any {
+            fun deserializeSchemaFile(
+                fileInputStream: FileInputStream,
+                versionNumber: Int
+            ): DatabaseBundle? {
                 return try {
                     SchemaBundle.deserialize(fileInputStream).database
                 } catch (th: Throwable) {
                     invalidAutoMigrationSchema(
                         "$versionNumber.json",
-                        databaseSchemaFolderPath.toString()
+                        databaseSchemaInFolderPath.toString()
                     )
+                    null
                 }
             }
 
-            if (validatedFromSchemaFile != null) {
-                val fromSchemaBundle = validatedFromSchemaFile.inputStream().use {
-                    deserializeSchemaFile(it, autoMigration.from)
-                }
-                val toSchemaBundle = if (autoMigration.to == latestDbSchema.version) {
+            val fromSchemaBundle = validatedFromSchemaFile.inputStream().use {
+                deserializeSchemaFile(it, autoMigration.from)
+            }
+            val toSchemaBundle =
+                if (autoMigration.to == latestDbSchema.version) {
                     latestDbSchema
                 } else {
                     val validatedToSchemaFile = getValidatedSchemaFile(
                         autoMigration.to,
-                        databaseSchemaFolderPath
-                    )
-                    if (validatedToSchemaFile != null) {
-                        validatedToSchemaFile.inputStream().use {
-                            deserializeSchemaFile(it, autoMigration.to)
-                        }
-                    } else {
-                        return@mapNotNull null
+                        databaseSchemaInFolderPath
+                    ) ?: return@mapNotNull null
+                    validatedToSchemaFile.inputStream().use {
+                        deserializeSchemaFile(it, autoMigration.to)
                     }
                 }
                 if (fromSchemaBundle !is DatabaseBundle || toSchemaBundle !is DatabaseBundle) {
@@ -221,15 +223,12 @@
                     return@mapNotNull null
                 }
 
-                AutoMigrationProcessor(
-                    context = context,
-                    spec = it.getAsType("spec")!!,
-                    fromSchemaBundle = fromSchemaBundle,
-                    toSchemaBundle = toSchemaBundle
-                ).process()
-            } else {
-                null
-            }
+            AutoMigrationProcessor(
+                context = context,
+                spec = it.getAsType("spec")!!,
+                fromSchemaBundle = fromSchemaBundle,
+                toSchemaBundle = toSchemaBundle
+            ).process()
         }
     }
 
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
index 0367eeb..1612b0c 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
@@ -539,9 +539,10 @@
         """.trim()
     }
 
-    val MISSING_SCHEMA_EXPORT_DIRECTORY = "Schema export directory is not provided to the" +
-        " annotation processor so we cannot export the schema. You can either provide" +
-        " `room.schemaLocation` annotation processor argument OR set exportSchema to false."
+    val MISSING_SCHEMA_EXPORT_DIRECTORY = "Schema export directory was not provided to the" +
+        " annotation processor so Room cannot export the schema. You can either provide" +
+        " `room.schemaLocation` annotation processor argument by applying the Room Gradle plugin" +
+        " (id 'androidx.room') OR set exportSchema to false."
 
     val INVALID_FOREIGN_KEY_ACTION = "Invalid foreign key action. It must be one of the constants" +
         " defined in ForeignKey.Action"
@@ -938,8 +939,11 @@
 
     fun invalidAutoMigrationSchema(schemaFile: String, schemaOutFolderPath: String): String {
         return "Found invalid schema file '$schemaFile.json' at the schema out " +
-            "folder: $schemaOutFolderPath. The schema files must be generated by Room. Cannot " +
-            "generate auto migrations."
+            "folder: $schemaOutFolderPath.\nIf you've modified the file, you might've broken the " +
+            "JSON format, try deleting the file and re-running the compiler.\n" +
+            "If you've not modified the file, please file a bug at " +
+            "https://issuetracker.google.com/issues/new?component=413107&template=1096568 " +
+            "with a sample app to reproduce the issue."
     }
 
     fun autoMigrationSchemasMustBeRoomGenerated(
@@ -1076,13 +1080,13 @@
         return "Conflicting @RenameColumn annotations found: [$annotations]"
     }
 
-    val AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF = "Cannot create auto migrations when export " +
-        "schema is OFF."
+    val AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF = "Cannot create auto migrations when " +
+        "exportSchema is false."
 
-    val AUTO_MIGRATION_SCHEMA_OUT_FOLDER_NULL = "Schema export directory is not provided to the" +
-        " annotation processor so we cannot import the schema. To generate auto migrations, you " +
-        "must provide `room.schemaLocation` annotation processor argument AND set exportSchema to" +
-        " true."
+    val AUTO_MIGRATION_SCHEMA_IN_FOLDER_NULL = "Schema import directory was not provided to the" +
+        " annotation processor so Room cannot read older schemas. To generate auto migrations," +
+        " you must provide `room.schemaLocation` annotation processor arguments by applying the" +
+        " Room Gradle plugin (id 'androidx.room') AND set exportSchema to true."
 
     fun tableWithConflictingPrefixFound(tableName: String): String {
         return "The new version of the schema contains '$tableName' a table name" +
@@ -1157,4 +1161,10 @@
         " the schema file and extracting it from the JAR but not for production builds, otherwise" +
         " the schema file will end up in the final artifact which is typically not desired. This" +
         " warning serves as a reminder to use room.exportSchemaResource cautiously."
+
+    val INVALID_GRADLE_PLUGIN_AND_SCHEMA_LOCATION_OPTION = "The Room Gradle plugin " +
+        "(id 'androidx.room') cannot be used with an explicit use of the annotation processor" +
+        "option `room.schemaLocation`, please remove the configuration of the option and " +
+        "configure the schema location via the plugin project extension: " +
+        "`room { schemaDirectory(...) }`."
 }
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/util/SchemaFileResolver.kt b/room/room-compiler/src/main/kotlin/androidx/room/util/SchemaFileResolver.kt
index b97d038..6cd884e 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/util/SchemaFileResolver.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/util/SchemaFileResolver.kt
@@ -28,8 +28,8 @@
 
     /**
      * Resolves the given path to a file. The path will be a either a sibling of Room's schema
-     * location or the folder itself as provided via the annotation processor option
-     * 'room.schemaLocation'.
+     * location or the folder itself as provided via the annotation processor options
+     * 'room.schemaLocation' or 'roomSchemaInput.
      */
     fun getFile(path: Path): File
 
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/vo/Database.kt b/room/room-compiler/src/main/kotlin/androidx/room/vo/Database.kt
index 1bcd843b..6674a85 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/vo/Database.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/vo/Database.kt
@@ -101,32 +101,26 @@
         DigestUtils.md5Hex(input)
     }
 
-    fun exportSchema(file: File) {
+    // Writes scheme file to output file, using the input file to check if the schema has changed
+    // otherwise it is not written.
+    fun exportSchema(inputFile: File, outputFile: File) {
         val schemaBundle = SchemaBundle(SchemaBundle.LATEST_FORMAT, bundle)
-        if (file.exists()) {
-            val existing = try {
-                file.inputStream().use {
-                    SchemaBundle.deserialize(it)
-                }
-            } catch (th: Throwable) {
-                throw IllegalStateException(
-                    """
-                    Cannot parse existing schema file: ${file.absolutePath}.
-                    If you've modified the file, you might've broken the JSON format, try
-                    deleting the file and re-running the compiler.
-                    If you've not modified the file, please file a bug at
-                    https://issuetracker.google.com/issues/new?component=413107&template=1096568
-                    with a sample app to reproduce the issue.
-                    """.trimIndent()
-                )
+        if (inputFile.exists()) {
+            val existing = inputFile.inputStream().use {
+                SchemaBundle.deserialize(it)
             }
+            // If existing schema file is the same as the current schema then do not write the file
+            // which helps the copy task configured by the Room Gradle Plugin skip execution due
+            // to empty variant schema output directory.
             if (existing.isSchemaEqual(schemaBundle)) {
                 return
             }
         }
-        SchemaBundle.serialize(schemaBundle, file)
+        SchemaBundle.serialize(schemaBundle, outputFile)
     }
 
+    // Writes scheme file to output stream, the stream should be for a resource otherwise use the
+    // file version of `exportSchema`.
     fun exportSchema(outputStream: OutputStream) {
         val schemaBundle = SchemaBundle(SchemaBundle.LATEST_FORMAT, bundle)
         SchemaBundle.serialize(schemaBundle, outputStream)
diff --git a/room/room-gradle-plugin/build.gradle b/room/room-gradle-plugin/build.gradle
new file mode 100644
index 0000000..8b12c66
--- /dev/null
+++ b/room/room-gradle-plugin/build.gradle
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 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.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.SdkResourceGenerator
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+    id("java-gradle-plugin")
+}
+
+configurations {
+    // Config for plugin classpath to be used during tests
+    testPlugin {
+        canBeConsumed = false
+        canBeResolved = true
+    }
+}
+
+dependencies {
+    implementation(libs.kotlinStdlib)
+    implementation(gradleApi())
+    implementation("com.android.tools.build:gradle:7.3.0")
+    compileOnly(libs.kotlinGradlePluginz)
+    compileOnly(libs.kspGradlePluginz)
+
+    testImplementation(project(":internal-testutils-gradle-plugin"))
+    testImplementation(gradleTestKit())
+    testImplementation(libs.junit)
+    testImplementation(libs.truth)
+    testImplementation(libs.testParameterInjector)
+
+    testPlugin(libs.kotlinGradlePluginz)
+    testPlugin(libs.kspGradlePluginz)
+}
+
+SdkResourceGenerator.generateForHostTest(project)
+
+// Configure the generating task of plugin-under-test-metadata.properties to
+// include additional dependencies for the injected plugin classpath that
+// are not present in the main runtime dependencies. This allows us to test
+// the KAPT / KSP plugins while keeping a compileOnly dep on the main source.
+tasks.withType(PluginUnderTestMetadata.class).named("pluginUnderTestMetadata").configure {
+    it.pluginClasspath.from(configurations.testPlugin)
+}
+
+// Configure publishing tasks to be dependencies of 'test' so those artifacts are available for
+// the test project executed with Gradle Test Kit.
+tasks.findByPath("test").dependsOn(
+        tasks.findByPath(":annotation:annotation-experimental:publish"),
+        tasks.findByPath(":room:room-common:publish"),
+        tasks.findByPath(":room:room-runtime:publish"),
+        tasks.findByPath(":room:room-migration:publish"),
+        tasks.findByPath(":room:room-compiler:publish"),
+        tasks.findByPath(":room:room-compiler-processing:publish"),
+        tasks.findByPath(":sqlite:sqlite:publish"),
+        tasks.findByPath(":sqlite:sqlite-framework:publish"),
+)
+
+gradlePlugin {
+    plugins {
+        room {
+            id = "androidx.room"
+            implementationClass = "androidx.room.gradle.RoomGradlePlugin"
+        }
+    }
+}
+
+androidx {
+    name = "Android Room Gradle Plugin"
+    type = LibraryType.GRADLE_PLUGIN
+    inceptionYear = "2023"
+    description = "Android Room Gradle Plugin"
+}
+
+validatePlugins {
+    enableStricterValidation = true
+}
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomExtension.kt b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomExtension.kt
new file mode 100644
index 0000000..dc10b3c
--- /dev/null
+++ b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomExtension.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 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.room.gradle
+
+import javax.inject.Inject
+import org.gradle.api.provider.Provider
+import org.gradle.api.provider.ProviderFactory
+
+open class RoomExtension @Inject constructor(private val providers: ProviderFactory) {
+    internal var schemaDirectory: Provider<String>? = null
+
+    // TODO(b/279748243): Consider adding overload that takes `org.gradle.api.file.Director`.
+
+    /**
+     * Sets the schema location where Room will output exported schema files.
+     *
+     * The location specified will be used as the base directory for schema files that will be
+     * generated per build variant. i.e. for a 'debug' build of the product flavor 'free' then a
+     * schema will be generated in
+     * `<schemaDirectory>/freeDebug/<database-package>/<database-version>.json`.
+     *
+     * See [Export Schemas Documentation](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
+     */
+    open fun schemaDirectory(path: String) {
+        schemaDirectory(providers.provider { path })
+    }
+
+    /**
+     * Sets the schema location where Room will output exported schema files.
+     *
+     * The location specified will be used as the base directory for schema files that will be
+     * generated per build variant. i.e. for a 'debug' build of the product flavor 'free' then a
+     * schema will be generated in
+     * `<schemaDirectory>/freeDebug/<database-package>/<database-version>.json`.
+     *
+     * See [Export Schemas Documentation](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
+     */
+    open fun schemaDirectory(path: Provider<String>) {
+        schemaDirectory = path
+    }
+}
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomGradlePlugin.kt b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomGradlePlugin.kt
new file mode 100644
index 0000000..9dacb6d
--- /dev/null
+++ b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/RoomGradlePlugin.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright 2023 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.room.gradle
+
+import com.android.build.api.AndroidPluginVersion
+import com.android.build.api.variant.AndroidComponentsExtension
+import com.android.build.api.variant.ComponentIdentity
+import com.android.build.api.variant.Variant
+import com.android.build.gradle.api.AndroidBasePlugin
+import com.google.devtools.ksp.gradle.KspTaskJvm
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+import kotlin.io.path.Path
+import kotlin.io.path.notExists
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.Directory
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.ProjectLayout
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.compile.JavaCompile
+import org.gradle.configurationcache.extensions.capitalized
+import org.gradle.process.CommandLineArgumentProvider
+import org.gradle.work.DisableCachingByDefault
+import org.jetbrains.kotlin.gradle.internal.KaptTask
+
+class RoomGradlePlugin @Inject constructor(
+    private val projectLayout: ProjectLayout,
+    private val objectFactory: ObjectFactory,
+) : Plugin<Project> {
+    override fun apply(project: Project) {
+        var configured = false
+        project.plugins.withType(AndroidBasePlugin::class.java) {
+            configured = true
+            configureRoom(project)
+        }
+        project.afterEvaluate {
+            project.check(configured) {
+                "The Room Gradle plugin can only be applied to an Android project."
+            }
+        }
+    }
+
+    private fun configureRoom(project: Project) {
+        // TODO(b/277899741): Validate version of Room supports the AP options configured by plugin.
+        val roomExtension =
+            project.extensions.create("room", RoomExtension::class.java)
+        val componentsExtension =
+            project.extensions.findByType(AndroidComponentsExtension::class.java)
+        project.check(componentsExtension != null) {
+            "Could not find the Android Gradle Plugin (AGP) extension, the Room Gradle plugin " +
+                "should be only applied to an Android projects."
+        }
+        project.check(componentsExtension.pluginVersion >= AndroidPluginVersion(7, 3)) {
+            "The Room Gradle plugin is only compatible with Android Gradle plugin (AGP) " +
+                "version 7.3.0 or higher (found ${componentsExtension.pluginVersion})."
+        }
+        componentsExtension.onVariants { variant ->
+            val locationProvider = roomExtension.schemaDirectory
+            project.check(locationProvider != null) {
+                "The Room Gradle plugin was applied but not schema location was specified. " +
+                    "Use the `room { schemaDirectory(...) }` DSL to specify one."
+            }
+            val schemaDirectory = locationProvider.get()
+            project.check(schemaDirectory.isNotEmpty()) {
+                "The schemaDirectory path must not be empty."
+            }
+            configureVariant(project, schemaDirectory, variant)
+        }
+    }
+
+    private fun configureVariant(
+        project: Project,
+        schemaDirectory: String,
+        variant: Variant
+    ) {
+        val androidVariantTaskNames = AndroidVariantsTaskNames(variant.name, variant)
+        val configureTask: (Task, ComponentIdentity) -> RoomSchemaDirectoryArgumentProvider = {
+                task, variantIdentity ->
+            val schemaDirectoryPath = Path(schemaDirectory, variantIdentity.name)
+            if (schemaDirectoryPath.notExists()) {
+                project.check(schemaDirectoryPath.toFile().mkdirs()) {
+                    "Unable to create directory: $schemaDirectoryPath"
+                }
+            }
+            val schemaInputDir = objectFactory.directoryProperty().apply {
+                set(project.file(schemaDirectoryPath))
+            }
+
+            val schemaOutputDir =
+                projectLayout.buildDirectory.dir("intermediates/room/schemas/${task.name}")
+
+            val copyTask = androidVariantTaskNames.copyTasks.getOrPut(variant.name) {
+                project.tasks.register(
+                    "copyRoomSchemas${variantIdentity.name.capitalize()}",
+                    RoomSchemaCopyTask::class.java
+                ) {
+                    it.schemaDirectory.set(schemaInputDir)
+                }
+            }
+            copyTask.configure { it.variantSchemaOutputDirectories.from(schemaOutputDir) }
+            task.finalizedBy(copyTask)
+
+            RoomSchemaDirectoryArgumentProvider(
+                forKsp = task.isKspTask(),
+                schemaInputDir = schemaInputDir,
+                schemaOutputDir = schemaOutputDir
+            )
+        }
+
+        configureJavaTasks(project, androidVariantTaskNames, configureTask)
+        configureKaptTasks(project, androidVariantTaskNames, configureTask)
+        configureKspTasks(project, androidVariantTaskNames, configureTask)
+
+        // TODO: Consider also setting up the androidTest and test source set to include the
+        //  relevant schema location so users can use MigrationTestHelper without additional
+        //  configuration.
+    }
+
+    private fun configureJavaTasks(
+        project: Project,
+        androidVariantsTaskNames: AndroidVariantsTaskNames,
+        configureBlock: (Task, ComponentIdentity) -> RoomSchemaDirectoryArgumentProvider
+    ) = project.tasks.withType(JavaCompile::class.java) { task ->
+        androidVariantsTaskNames.withJavaCompile(task.name)?.let { variantIdentity ->
+            val argProvider = configureBlock.invoke(task, variantIdentity)
+            task.options.compilerArgumentProviders.add(argProvider)
+        }
+    }
+
+    private fun configureKaptTasks(
+        project: Project,
+        androidVariantsTaskNames: AndroidVariantsTaskNames,
+        configureBlock: (Task, ComponentIdentity) -> RoomSchemaDirectoryArgumentProvider
+    ) = project.plugins.withId("kotlin-kapt") {
+        project.tasks.withType(KaptTask::class.java) { task ->
+            androidVariantsTaskNames.withKaptTask(task.name)?.let { variantIdentity ->
+                val argProvider = configureBlock.invoke(task, variantIdentity)
+                // TODO: Update once KT-58009 is fixed.
+                try {
+                    // Because of KT-58009, we need to add a `listOf(argProvider)` instead
+                    // of `argProvider`.
+                    task.annotationProcessorOptionProviders.add(listOf(argProvider))
+                } catch (e: Throwable) {
+                    // Once KT-58009 is fixed, adding `listOf(argProvider)` will fail, we will
+                    // pass `argProvider` instead, which is the correct way.
+                    task.annotationProcessorOptionProviders.add(argProvider)
+                }
+            }
+        }
+    }
+
+    private fun configureKspTasks(
+        project: Project,
+        androidVariantsTaskNames: AndroidVariantsTaskNames,
+        configureBlock: (Task, ComponentIdentity) -> RoomSchemaDirectoryArgumentProvider
+    ) = project.plugins.withId("com.google.devtools.ksp") {
+        project.tasks.withType(KspTaskJvm::class.java) { task ->
+            androidVariantsTaskNames.withKspTaskJvm(task.name)?.let { variantIdentity ->
+                val argProvider = configureBlock.invoke(task, variantIdentity)
+                task.commandLineArgumentProviders.add(argProvider)
+            }
+        }
+    }
+
+    internal class AndroidVariantsTaskNames(
+        private val variantName: String,
+        private val variantIdentity: ComponentIdentity
+    ) {
+        // Variant name to copy task
+        val copyTasks = mutableMapOf<String, TaskProvider<RoomSchemaCopyTask>>()
+
+        private val javaCompileName by lazy {
+            "compile${variantName.capitalized()}JavaWithJavac"
+        }
+
+        private val kaptTaskName by lazy {
+            "kapt${variantName.capitalized()}Kotlin"
+        }
+
+        private val kspTaskJvm by lazy {
+            "ksp${variantName.capitalized()}Kotlin"
+        }
+
+        fun withJavaCompile(taskName: String) =
+            if (taskName == javaCompileName) variantIdentity else null
+
+        fun withKaptTask(taskName: String) =
+            if (taskName == kaptTaskName) variantIdentity else null
+
+        fun withKspTaskJvm(taskName: String) =
+            if (taskName == kspTaskJvm) variantIdentity else null
+    }
+
+    @DisableCachingByDefault(because = "Simple disk bound task.")
+    abstract class RoomSchemaCopyTask : DefaultTask() {
+        @get:InputFiles
+        @get:SkipWhenEmpty
+        @get:IgnoreEmptyDirectories
+        @get:PathSensitive(PathSensitivity.RELATIVE)
+        abstract val variantSchemaOutputDirectories: ConfigurableFileCollection
+
+        @get:Internal
+        abstract val schemaDirectory: DirectoryProperty
+
+        @TaskAction
+        fun copySchemas() {
+            variantSchemaOutputDirectories.files
+                .filter { it.exists() }
+                .forEach {
+                    // TODO(b/278266663): Error when two same relative path schemas are found in out
+                    //  dirs and their content is different an indicator of an inconsistency between
+                    //  the compile tasks of the same variant.
+                    it.copyRecursively(schemaDirectory.get().asFile, overwrite = true)
+                }
+        }
+    }
+
+    class RoomSchemaDirectoryArgumentProvider(
+        @get:Input
+        val forKsp: Boolean,
+        @get:InputFiles
+        @get:PathSensitive(PathSensitivity.RELATIVE)
+        val schemaInputDir: Provider<Directory>,
+        @get:OutputDirectory
+        val schemaOutputDir: Provider<Directory>
+    ) : CommandLineArgumentProvider {
+        override fun asArguments() = buildList {
+            val prefix = if (forKsp) "" else "-A"
+            add("${prefix}room.internal.schemaInput=${schemaInputDir.get().asFile.path}")
+            add("${prefix}room.internal.schemaOutput=${schemaOutputDir.get().asFile.path}")
+        }
+    }
+
+    companion object {
+        internal fun String.capitalize(): String = this.replaceFirstChar {
+            if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString()
+        }
+
+        internal fun Task.isKspTask(): Boolean = try {
+            val kspTaskClass = Class.forName("com.google.devtools.ksp.gradle.KspTask")
+            kspTaskClass.isAssignableFrom(this::class.java)
+        } catch (ex: ClassNotFoundException) {
+            false
+        }
+
+        @OptIn(ExperimentalContracts::class)
+        internal fun Project.check(value: Boolean, lazyMessage: () -> String) {
+            contract {
+                returns() implies value
+            }
+            if (isGradleSyncRunning()) return
+            if (!value) {
+                throw GradleException(lazyMessage())
+            }
+        }
+
+        private fun Project.isGradleSyncRunning() = gradleSyncProps.any {
+            it in this.properties && this.properties[it].toString().toBoolean()
+        }
+
+        private val gradleSyncProps by lazy {
+            listOf(
+                "android.injected.build.model.v2",
+                "android.injected.build.model.only",
+                "android.injected.build.model.only.advanced",
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/test/java/androidx/room/gradle/RoomGradlePluginTest.kt b/room/room-gradle-plugin/src/test/java/androidx/room/gradle/RoomGradlePluginTest.kt
new file mode 100644
index 0000000..ce82693
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/java/androidx/room/gradle/RoomGradlePluginTest.kt
@@ -0,0 +1,356 @@
+/*
+ * Copyright 2023 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.room.gradle
+
+import androidx.testutils.gradle.ProjectSetupRule
+import com.google.common.truth.Truth.assertThat
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.google.testing.junit.testparameterinjector.TestParameterInjector
+import java.io.File
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.TaskOutcome
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(TestParameterInjector::class)
+class RoomGradlePluginTest(
+    @TestParameter val backend: ProcessingBackend
+) {
+    @get:Rule
+    val projectSetup = ProjectSetupRule()
+
+    private val roomVersion by lazy {
+        projectSetup.getLibraryLatestVersionInLocalRepo("androidx/room/room-compiler")
+    }
+
+    private fun setup(projectName: String, projectRoot: File = projectSetup.rootDir) {
+        // copy test project
+        File("src/test/test-data/$projectName").copyRecursively(projectRoot)
+
+        if (backend.isForKotlin) {
+            // copy Kotlin database file
+            File("src/test/test-data/kotlin/MyDatabase.kt").let {
+                it.copyTo(projectRoot.resolve("src/main/java/room/testapp/${it.name}"))
+            }
+        } else {
+            // copy Java database file
+            File("src/test/test-data/java/MyDatabase.java").let {
+                it.copyTo(projectRoot.resolve("src/main/java/room/testapp/${it.name}"))
+            }
+        }
+
+        val additionalPluginsBlock = when (backend) {
+            ProcessingBackend.JAVAC ->
+                ""
+            ProcessingBackend.KAPT ->
+                """
+                    id('kotlin-android')
+                    id('kotlin-kapt')
+                """
+            ProcessingBackend.KSP ->
+                """
+                    id('kotlin-android')
+                    id('com.google.devtools.ksp')
+                """
+        }
+
+        val repositoriesBlock = buildString {
+            appendLine("repositories {")
+            projectSetup.allRepositoryPaths.forEach {
+                appendLine("""maven { url "$it" }""")
+            }
+            appendLine("}")
+        }
+
+        val processorConfig = when (backend) {
+            ProcessingBackend.JAVAC -> "annotationProcessor"
+            ProcessingBackend.KAPT -> "kapt"
+            ProcessingBackend.KSP -> "ksp"
+        }
+
+        val kotlinJvmTargetBlock = if (backend.isForKotlin) {
+            """
+            tasks.withType(
+                org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+            ).configureEach {
+                kotlinOptions {
+                    jvmTarget = "1.8"
+                }
+            }
+            """.trimIndent()
+        } else {
+            ""
+        }
+
+        // set up build file
+        File(projectRoot, "build.gradle").writeText(
+            """
+            plugins {
+                id('com.android.application')
+                id('androidx.room')
+                $additionalPluginsBlock
+            }
+
+            $repositoriesBlock
+
+            %s
+
+            dependencies {
+                // Uses latest Room built from tip of tree
+                implementation "androidx.room:room-runtime:$roomVersion"
+                $processorConfig "androidx.room:room-compiler:$roomVersion"
+            }
+
+            android {
+                namespace "room.testapp"
+                compileOptions {
+                  sourceCompatibility = JavaVersion.VERSION_1_8
+                  targetCompatibility = JavaVersion.VERSION_1_8
+                }
+            }
+
+            $kotlinJvmTargetBlock
+
+            room {
+                schemaDirectory("${'$'}projectDir/schemas")
+            }
+
+            """
+                .trimMargin()
+                // doing format instead of "$projectSetup.androidProject" on purpose,
+                // because otherwise trimIndent will mess with formatting
+                .format(projectSetup.androidProject)
+
+        )
+    }
+
+    @Test
+    fun testWorkflow() {
+        setup("simple-project")
+
+        // First clean build, all tasks need to run
+        runGradleTasks(CLEAN_TASK, COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.SUCCESS)
+        }
+
+        // Schema file at version 1 is created
+        var schemaOneTimestamp: Long
+        projectSetup.rootDir.resolve("schemas/debug/room.testapp.MyDatabase/1.json").let {
+            assertThat(it.exists()).isTrue()
+            schemaOneTimestamp = it.lastModified()
+        }
+
+        // Incremental build, compile task re-runs because schema 1 is used as input, but no copy
+        // is done since schema has not changed.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Incremental build, everything is up to date.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.UP_TO_DATE)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Make a change that changes the schema at version 1
+        searchAndReplace(
+            file = projectSetup.rootDir.resolve("src/main/java/room/testapp/MyEntity.java"),
+            search = "// Insert-change",
+            replace = "public String text;"
+        )
+
+        // Incremental build, new schema for version 1 is generated and copied.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.SUCCESS)
+        }
+
+        // Check schema file at version 1 is updated
+        projectSetup.rootDir.resolve("schemas/debug/room.testapp.MyDatabase/1.json").let {
+            assertThat(it.exists()).isTrue()
+            assertThat(schemaOneTimestamp).isNotEqualTo(it.lastModified())
+            schemaOneTimestamp = it.lastModified()
+        }
+
+        // Incremental build, compile task re-runs because schema 1 is used as input (it changed),
+        // but no copy is done since schema has not changed.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Incremental build, everything is up to date.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.UP_TO_DATE)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Add a new file, it does not change the schema
+        projectSetup.rootDir.resolve("src/main/java/room/testapp/NewUtil.java")
+            .writeText("""
+            package room.testapp;
+            public class NewUtil {
+            }
+            """.trimIndent())
+
+        // Incremental build, compile task re-runs because of new source, but no schema is copied
+        // since Room processor didn't even run.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Incremental build, everything is up to date.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.UP_TO_DATE)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.NO_SOURCE)
+        }
+
+        // Change the database version to 2
+        val dbFile = if (backend.isForKotlin) "MyDatabase.kt" else "MyDatabase.java"
+        searchAndReplace(
+            file = projectSetup.rootDir.resolve("src/main/java/room/testapp/$dbFile"),
+            search = "version = 1",
+            replace = "version = 2"
+        )
+
+        // Incremental build, due to the version change a new schema file is generated.
+        runGradleTasks(COMPILE_TASK).let { result ->
+            result.assertTaskOutcome(COMPILE_TASK, TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(COPY_TASK, TaskOutcome.SUCCESS)
+        }
+
+        // Check schema file at version 1 is still present and unchanged.
+        projectSetup.rootDir.resolve("schemas/debug/room.testapp.MyDatabase/1.json").let {
+            assertThat(it.exists()).isTrue()
+            assertThat(schemaOneTimestamp).isEqualTo(it.lastModified())
+        }
+
+        // Check schema file at version 2 is created and copied.
+        projectSetup.rootDir.resolve("schemas/debug/room.testapp.MyDatabase/2.json").let {
+            assertThat(it.exists()).isTrue()
+        }
+    }
+
+    @Test
+    fun testFlavoredProject() {
+        setup("flavored-project")
+
+        File(projectSetup.rootDir, "build.gradle").appendText(
+            """
+            android {
+                flavorDimensions "mode"
+                productFlavors {
+                    flavorOne {
+                        dimension "mode"
+                    }
+                    flavorTwo {
+                        dimension "mode"
+                    }
+                }
+            }
+            """.trimIndent()
+        )
+
+        runGradleTasks(
+            CLEAN_TASK,
+            "compileFlavorOneDebugJavaWithJavac",
+            "compileFlavorTwoDebugJavaWithJavac"
+        ).let { result ->
+            result.assertTaskOutcome(":compileFlavorOneDebugJavaWithJavac", TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(":compileFlavorTwoDebugJavaWithJavac", TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(":copyRoomSchemasFlavorOneDebug", TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(":copyRoomSchemasFlavorTwoDebug", TaskOutcome.SUCCESS)
+        }
+        // Check schema files are generated for both flavor, each in its own folder.
+        val flavorOneSchema = projectSetup.rootDir.resolve(
+            "schemas/flavorOneDebug/room.testapp.MyDatabase/1.json"
+        )
+        val flavorTwoSchema = projectSetup.rootDir.resolve(
+            "schemas/flavorTwoDebug/room.testapp.MyDatabase/1.json"
+        )
+        assertThat(flavorOneSchema.exists()).isTrue()
+        assertThat(flavorTwoSchema.exists()).isTrue()
+        // Check the schemas in both flavors are different
+        assertThat(flavorOneSchema.readText()).isNotEqualTo(flavorTwoSchema.readText())
+    }
+
+    @Test
+    fun testMoreBuildTypesProject() {
+        setup("simple-project")
+
+        File(projectSetup.rootDir, "build.gradle").appendText(
+            """
+            android {
+                buildTypes {
+                    staging {
+                        initWith debug
+                        applicationIdSuffix ".debugStaging"
+                    }
+                }
+            }
+            """.trimIndent()
+        )
+
+        runGradleTasks(CLEAN_TASK, "compileStagingJavaWithJavac",).let { result ->
+            result.assertTaskOutcome(":compileStagingJavaWithJavac", TaskOutcome.SUCCESS)
+            result.assertTaskOutcome(":copyRoomSchemasStaging", TaskOutcome.SUCCESS)
+        }
+        val schemeFile = projectSetup.rootDir.resolve(
+            "schemas/staging/room.testapp.MyDatabase/1.json"
+        )
+        assertThat(schemeFile.exists()).isTrue()
+    }
+
+    private fun runGradleTasks(
+        vararg args: String,
+        projectDir: File = projectSetup.rootDir
+    ): BuildResult {
+        return GradleRunner.create()
+            .withProjectDir(projectDir)
+            .withPluginClasspath()
+            // workaround for b/231154556
+            .withArguments("-Dorg.gradle.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m", *args)
+            .build()
+    }
+
+    private fun BuildResult.assertTaskOutcome(taskPath: String, outcome: TaskOutcome) {
+        assertThat(this.task(taskPath)!!.outcome).isEqualTo(outcome)
+    }
+
+    private fun searchAndReplace(file: File, search: String, replace: String) {
+        file.writeText(file.readText().replace(search, replace))
+    }
+
+    enum class ProcessingBackend(
+        val isForKotlin: Boolean
+    ) {
+        JAVAC(false),
+        KAPT(true),
+        KSP(true)
+    }
+
+    companion object {
+        private const val CLEAN_TASK = ":clean"
+        private const val COMPILE_TASK = ":compileDebugJavaWithJavac"
+        private const val COPY_TASK = ":copyRoomSchemasDebug"
+    }
+}
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorOne/java/room/testapp/MyEntity.java b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorOne/java/room/testapp/MyEntity.java
new file mode 100644
index 0000000..8959321
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorOne/java/room/testapp/MyEntity.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity
+public class MyEntity {
+    @PrimaryKey
+    public long id;
+}
diff --git a/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorTwo/java/room/testapp/MyEntity.java b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorTwo/java/room/testapp/MyEntity.java
new file mode 100644
index 0000000..ea059e9
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/flavorTwo/java/room/testapp/MyEntity.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity
+public class MyEntity {
+    @PrimaryKey
+    public long id;
+
+    public boolean flavorTwoColumn;
+}
diff --git a/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/AndroidManifest.xml b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1e3e702
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2023 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.
+  -->
+<manifest/>
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/java/room/testapp/MyDao.java b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/java/room/testapp/MyDao.java
new file mode 100644
index 0000000..727ffbc
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/flavored-project/src/main/java/room/testapp/MyDao.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Dao;
+import androidx.room.Query;
+
+import java.util.List;
+
+@Dao
+public interface MyDao {
+    @Query("SELECT * FROM MyEntity")
+    List<MyEntity> getAll();
+}
diff --git a/room/room-gradle-plugin/src/test/test-data/java/MyDatabase.java b/room/room-gradle-plugin/src/test/test-data/java/MyDatabase.java
new file mode 100644
index 0000000..29851d4
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/java/MyDatabase.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Database;
+import androidx.room.RoomDatabase;
+
+@Database(entities = { MyEntity.class }, version = 1)
+public abstract class MyDatabase extends RoomDatabase {
+    public abstract MyDao getDao();
+}
diff --git a/room/room-gradle-plugin/src/test/test-data/kotlin/MyDatabase.kt b/room/room-gradle-plugin/src/test/test-data/kotlin/MyDatabase.kt
new file mode 100644
index 0000000..8b03a9f
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/kotlin/MyDatabase.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 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 room.testapp
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(entities = [MyEntity::class], version = 1)
+abstract class MyDatabase : RoomDatabase() {
+    abstract fun getDao(): MyDao
+}
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/AndroidManifest.xml b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1e3e702
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2023 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.
+  -->
+<manifest/>
\ No newline at end of file
diff --git a/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyDao.java b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyDao.java
new file mode 100644
index 0000000..727ffbc
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyDao.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Dao;
+import androidx.room.Query;
+
+import java.util.List;
+
+@Dao
+public interface MyDao {
+    @Query("SELECT * FROM MyEntity")
+    List<MyEntity> getAll();
+}
diff --git a/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyEntity.java b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyEntity.java
new file mode 100644
index 0000000..3e4c1c0
--- /dev/null
+++ b/room/room-gradle-plugin/src/test/test-data/simple-project/src/main/java/room/testapp/MyEntity.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 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 room.testapp;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity
+public class MyEntity {
+    @PrimaryKey
+    public long id;
+
+    // Insert-change
+}
diff --git a/settings.gradle b/settings.gradle
index 6dc200a..aa1e705 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,6 +1,6 @@
-import groovy.transform.Field
 import androidx.build.gradle.gcpbuildcache.GcpBuildCache
 import androidx.build.gradle.gcpbuildcache.GcpBuildCacheServiceFactory
+import groovy.transform.Field
 
 import java.util.regex.Matcher
 import java.util.regex.Pattern
@@ -918,6 +918,7 @@
 includeProject(":room:room-compiler-processing", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN])
 includeProject(":room:room-compiler-processing-testing", [BuildType.MAIN, BuildType.COMPOSE, BuildType.FLAN])
 includeProject(":room:room-guava", [BuildType.MAIN])
+includeProject(":room:room-gradle-plugin", [BuildType.MAIN])
 includeProject(":room:room-ktx", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":room:room-migration", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":room:room-paging", [BuildType.MAIN, BuildType.COMPOSE])
diff --git a/testutils/testutils-gradle-plugin/src/main/java/androidx/testutils/gradle/ProjectSetupRule.kt b/testutils/testutils-gradle-plugin/src/main/java/androidx/testutils/gradle/ProjectSetupRule.kt
index 7bd12a1c..0724f1f 100644
--- a/testutils/testutils-gradle-plugin/src/main/java/androidx/testutils/gradle/ProjectSetupRule.kt
+++ b/testutils/testutils-gradle-plugin/src/main/java/androidx/testutils/gradle/ProjectSetupRule.kt
@@ -16,12 +16,15 @@
 
 package androidx.testutils.gradle
 
+import java.io.File
+import java.util.Properties
+import javax.xml.parsers.DocumentBuilderFactory
+import javax.xml.xpath.XPathConstants
+import javax.xml.xpath.XPathFactory
 import org.junit.rules.ExternalResource
 import org.junit.rules.TemporaryFolder
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
-import java.io.File
-import java.util.Properties
 
 /**
  * Test rule that helps to setup android project in tests that run gradle.
@@ -130,6 +133,42 @@
         }
     }
 
+    /**
+     * Gets the latest version of a published library.
+     *
+     * Note that the library must have been locally published to locate its latest version, this
+     * can be done in test by adding :publish as a test dependency, for example:
+     * ```
+     * tasks.findByPath("test")
+     *   .dependsOn(tasks.findByPath(":room:room-compiler:publish")
+     * ```
+     *
+     * @param path - The library m2 path e.g. "androidx/room/room-compiler"
+     */
+    fun getLibraryLatestVersionInLocalRepo(path: String): String {
+        val metadataFile = File(props.tipOfTreeMavenRepoPath)
+            .resolve(path)
+            .resolve("maven-metadata.xml")
+        check(metadataFile.exists()) {
+            "Cannot find room metadata file in ${metadataFile.absolutePath}"
+        }
+        check(metadataFile.isFile) {
+            "Metadata file should be a file but it is not."
+        }
+        val xmlDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
+            .parse(metadataFile)
+        val latestVersionNode = XPathFactory.newInstance().newXPath()
+            .compile("/metadata/versioning/latest").evaluate(
+                xmlDoc, XPathConstants.STRING
+            )
+        check(latestVersionNode is String) {
+            """Unexpected node for latest version:
+                $latestVersionNode / ${latestVersionNode::class.java}
+            """.trimIndent()
+        }
+        return latestVersionNode
+    }
+
     private fun copyLocalProperties() {
         var foundSdk = false