1 /*
<lambda>null2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.build.docs
18 
19 import androidx.build.configureTaskTimeouts
20 import androidx.build.dackka.DackkaTask
21 import androidx.build.dackka.GenerateMetadataTask
22 import androidx.build.defaultAndroidConfig
23 import androidx.build.getAndroidJar
24 import androidx.build.getBuildId
25 import androidx.build.getCheckoutRoot
26 import androidx.build.getDistributionDirectory
27 import androidx.build.getKeystore
28 import androidx.build.getLibraryByName
29 import androidx.build.getSupportRootFolder
30 import androidx.build.metalava.versionMetadataUsage
31 import androidx.build.sources.PROJECT_STRUCTURE_METADATA_FILENAME
32 import androidx.build.sources.multiplatformUsage
33 import androidx.build.versionCatalog
34 import androidx.build.workaroundPrebuiltTakingPrecedenceOverProject
35 import com.android.build.api.attributes.BuildTypeAttr
36 import com.android.build.api.dsl.LibraryExtension
37 import com.android.build.gradle.LibraryPlugin
38 import com.google.gson.GsonBuilder
39 import java.io.File
40 import java.io.FileNotFoundException
41 import java.time.Duration
42 import java.time.LocalDateTime
43 import java.util.concurrent.TimeUnit
44 import javax.inject.Inject
45 import org.gradle.api.DefaultTask
46 import org.gradle.api.Plugin
47 import org.gradle.api.Project
48 import org.gradle.api.Task
49 import org.gradle.api.artifacts.ComponentMetadataContext
50 import org.gradle.api.artifacts.ComponentMetadataRule
51 import org.gradle.api.artifacts.Configuration
52 import org.gradle.api.attributes.Attribute
53 import org.gradle.api.attributes.Bundling
54 import org.gradle.api.attributes.Category
55 import org.gradle.api.attributes.DocsType
56 import org.gradle.api.attributes.LibraryElements
57 import org.gradle.api.attributes.Usage
58 import org.gradle.api.file.ArchiveOperations
59 import org.gradle.api.file.Directory
60 import org.gradle.api.file.DirectoryProperty
61 import org.gradle.api.file.DuplicatesStrategy
62 import org.gradle.api.file.FileCollection
63 import org.gradle.api.file.FileSystemOperations
64 import org.gradle.api.file.FileTree
65 import org.gradle.api.file.ProjectLayout
66 import org.gradle.api.file.RegularFile
67 import org.gradle.api.file.RegularFileProperty
68 import org.gradle.api.model.ObjectFactory
69 import org.gradle.api.plugins.JavaBasePlugin
70 import org.gradle.api.provider.Property
71 import org.gradle.api.provider.Provider
72 import org.gradle.api.tasks.CacheableTask
73 import org.gradle.api.tasks.Classpath
74 import org.gradle.api.tasks.InputFiles
75 import org.gradle.api.tasks.Internal
76 import org.gradle.api.tasks.OutputDirectory
77 import org.gradle.api.tasks.OutputFile
78 import org.gradle.api.tasks.PathSensitive
79 import org.gradle.api.tasks.PathSensitivity
80 import org.gradle.api.tasks.Sync
81 import org.gradle.api.tasks.TaskAction
82 import org.gradle.api.tasks.TaskProvider
83 import org.gradle.api.tasks.bundling.Zip
84 import org.gradle.api.tasks.testing.Test
85 import org.gradle.kotlin.dsl.all
86 import org.gradle.kotlin.dsl.getByType
87 import org.gradle.kotlin.dsl.named
88 import org.gradle.kotlin.dsl.register
89 import org.gradle.work.DisableCachingByDefault
90 
91 /**
92  * Plugin that allows to build documentation for a given set of prebuilt and tip of tree projects.
93  */
94 abstract class AndroidXDocsImplPlugin : Plugin<Project> {
95     lateinit var docsType: String
96     lateinit var docsSourcesConfiguration: Configuration
97     lateinit var multiplatformDocsSourcesConfiguration: Configuration
98     lateinit var samplesSourcesConfiguration: Configuration
99     lateinit var versionMetadataConfiguration: Configuration
100     lateinit var dependencyClasspath: FileCollection
101 
102     @get:Inject abstract val archiveOperations: ArchiveOperations
103 
104     override fun apply(project: Project) {
105         docsType = project.name.removePrefix("docs-")
106         project.plugins.configureEach { plugin ->
107             when (plugin) {
108                 is LibraryPlugin -> {
109                     val libraryExtension = project.extensions.getByType<LibraryExtension>()
110                     libraryExtension.compileSdk =
111                         project.defaultAndroidConfig.latestStableCompileSdk
112                     libraryExtension.buildToolsVersion =
113                         project.defaultAndroidConfig.buildToolsVersion
114 
115                     // Use a local debug keystore to avoid build server issues.
116                     val debugSigningConfig = libraryExtension.signingConfigs.getByName("debug")
117                     debugSigningConfig.storeFile = project.getKeystore()
118                     libraryExtension.buildTypes.configureEach { buildType ->
119                         // Sign all the builds (including release) with debug key
120                         buildType.signingConfig = debugSigningConfig
121                     }
122                 }
123             }
124         }
125         disableUnneededTasks(project)
126         createConfigurations(project)
127         val buildOnServer =
128             project.tasks.register<DocsBuildOnServer>("buildOnServer") {
129                 buildId = getBuildId()
130                 docsType = this@AndroidXDocsImplPlugin.docsType
131                 distributionDirectory = project.getDistributionDirectory()
132             }
133 
134         val unzippedDeprecatedSamplesSources =
135             project.layout.buildDirectory.dir("unzippedDeprecatedSampleSources")
136         val deprecatedUnzipSamplesTask =
137             configureUnzipTask(
138                 project,
139                 "unzipSampleSourcesDeprecated",
140                 unzippedDeprecatedSamplesSources,
141                 samplesSourcesConfiguration
142             )
143         val unzippedKmpSamplesSourcesDirectory =
144             project.layout.buildDirectory.dir("unzippedMultiplatformSampleSources")
145         val unzippedJvmSamplesSourcesDirectory =
146             project.layout.buildDirectory.dir("unzippedJvmSampleSources")
147         val unzippedJvmSourcesDirectory = project.layout.buildDirectory.dir("unzippedJvmSources")
148         val unzippedMultiplatformSourcesDirectory =
149             project.layout.buildDirectory.dir("unzippedMultiplatformSources")
150         val mergedProjectMetadata =
151             project.layout.buildDirectory.file(
152                 "project_metadata/$PROJECT_STRUCTURE_METADATA_FILENAME"
153             )
154         val (unzipJvmSourcesTask, unzipJvmSamplesTask) =
155             configureUnzipJvmSourcesTasks(
156                 project,
157                 unzippedJvmSourcesDirectory,
158                 unzippedJvmSamplesSourcesDirectory,
159                 docsSourcesConfiguration
160             )
161         val configureMultiplatformSourcesTask =
162             configureMultiplatformInputsTasks(
163                 project,
164                 unzippedMultiplatformSourcesDirectory,
165                 unzippedKmpSamplesSourcesDirectory,
166                 multiplatformDocsSourcesConfiguration,
167                 mergedProjectMetadata
168             )
169 
170         configureDackka(
171             project = project,
172             unzippedJvmSourcesDirectory = unzippedJvmSourcesDirectory,
173             unzippedMultiplatformSourcesDirectory = unzippedMultiplatformSourcesDirectory,
174             unzipJvmSourcesTask = unzipJvmSourcesTask,
175             configureMultiplatformSourcesTask = configureMultiplatformSourcesTask,
176             unzippedDeprecatedSamplesSources = unzippedDeprecatedSamplesSources,
177             unzipDeprecatedSamplesTask = deprecatedUnzipSamplesTask,
178             unzippedJvmSamplesSources = unzippedJvmSamplesSourcesDirectory,
179             unzipJvmSamplesTask = unzipJvmSamplesTask,
180             unzippedKmpSamplesSources = unzippedKmpSamplesSourcesDirectory,
181             dependencyClasspath = dependencyClasspath,
182             buildOnServer = buildOnServer,
183             docsConfiguration = docsSourcesConfiguration,
184             multiplatformDocsConfiguration = multiplatformDocsSourcesConfiguration,
185             mergedProjectMetadata = mergedProjectMetadata
186         )
187 
188         project.configureTaskTimeouts()
189         project.workaroundPrebuiltTakingPrecedenceOverProject()
190     }
191 
192     /**
193      * Creates and configures a task that will build a list of all sources for projects in
194      * [docsConfiguration] configuration, resolve them and put them to [destinationDirectory].
195      */
196     private fun configureUnzipTask(
197         project: Project,
198         taskName: String,
199         destinationDirectory: Provider<Directory>,
200         docsConfiguration: Configuration
201     ): TaskProvider<Sync> {
202         return project.tasks.register(taskName, Sync::class.java) { task ->
203             val sources = docsConfiguration.incoming.artifactView {}.files
204             // Store archiveOperations into a local variable to prevent access to the plugin
205             // during the task execution, as that breaks configuration caching.
206             val localVar = archiveOperations
207             task.from(
208                 sources.elements.map { jars ->
209                     jars.map { jar ->
210                         localVar.zipTree(jar).matching {
211                             // Filter out files that documentation tools cannot process.
212                             it.exclude("**/*.MF")
213                             it.exclude("**/*.aidl")
214                             it.exclude("**/META-INF/**")
215                             it.exclude("**/OWNERS")
216                             it.exclude("**/package.html")
217                             it.exclude("**/*.md")
218                         }
219                     }
220                 }
221             )
222             task.into(destinationDirectory)
223             // TODO(123020809) remove this filter once it is no longer necessary to prevent Dokka
224             //  from failing
225             val regex = Regex("@attr ref ([^*]*)styleable#([^_*]*)_([^*]*)$")
226             task.filter { line -> regex.replace(line, "{@link $1attr#$3}") }
227         }
228     }
229 
230     /**
231      * Creates and configures a task that builds a list of select sources from jars and places them
232      * in [sourcesDestinationDirectory], partitioning samples into [samplesDestinationDirectory].
233      *
234      * This is a modified version of [configureUnzipTask], customized for Dackka usage.
235      */
236     private fun configureUnzipJvmSourcesTasks(
237         project: Project,
238         sourcesDestinationDirectory: Provider<Directory>,
239         samplesDestinationDirectory: Provider<Directory>,
240         docsConfiguration: Configuration
241     ): Pair<TaskProvider<Sync>, TaskProvider<Sync>> {
242         val pairProvider =
243             docsConfiguration.incoming
244                 .artifactView {}
245                 .files
246                 .elements
247                 .map {
248                     it.map { it.asFile }.toSortedSet().partition { "samples" !in it.toString() }
249                 }
250         return project.tasks.register("unzipJvmSources", Sync::class.java) { task ->
251             // Store archiveOperations into a local variable to prevent access to the plugin
252             // during the task execution, as that breaks configuration caching.
253             val localVar = archiveOperations
254             val tempDir = project.layout.buildDirectory.dir("tmp/JvmSources").get().asFile
255             // Get rid of stale files in the directory
256             tempDir.deleteRecursively()
257             task.into(sourcesDestinationDirectory)
258             task.from(
259                 pairProvider
260                     .map { it.first }
261                     .map {
262                         it.map { jar ->
263                             localVar
264                                 .zipTree(jar)
265                                 .matching { it.exclude("**/META-INF/MANIFEST.MF") }
266                                 .rewriteImageTagsAndCopy(tempDir)
267                             tempDir
268                         }
269                     }
270             )
271             // Files with the same path in different source jars of the same library will lead to
272             // some classes/methods not appearing in the docs.
273             task.duplicatesStrategy = DuplicatesStrategy.WARN
274         } to
275             project.tasks.register("unzipSampleSources", Sync::class.java) { task ->
276                 // Store archiveOperations into a local variable to prevent access to the plugin
277                 // during the task execution, as that breaks configuration caching.
278                 val localVar = archiveOperations
279                 val tempDir = project.layout.buildDirectory.dir("tmp/SampleSources").get().asFile
280                 // Get rid of stale files in the directory
281                 tempDir.deleteRecursively()
282                 task.into(samplesDestinationDirectory)
283                 task.from(
284                     pairProvider
285                         .map { it.second }
286                         .map {
287                             it.map { jar ->
288                                 localVar
289                                     .zipTree(jar)
290                                     .matching { it.exclude("**/META-INF/MANIFEST.MF") }
291                                     .rewriteImageTagsAndCopy(tempDir)
292                                 tempDir
293                             }
294                         }
295                 )
296                 // We expect this to happen when multiple libraries use the same sample, e.g.
297                 // paging.
298                 task.duplicatesStrategy = DuplicatesStrategy.INCLUDE
299             }
300     }
301 
302     /**
303      * Creates multiple tasks to unzip multiplatform sources and merge their metadata to be used as
304      * input for Dackka. Returns a single umbrella task which depends on the others.
305      */
306     private fun configureMultiplatformInputsTasks(
307         project: Project,
308         unzippedMultiplatformSourcesDirectory: Provider<Directory>,
309         unzippedMultiplatformSamplesDirectory: Provider<Directory>,
310         multiplatformDocsSourcesConfiguration: Configuration,
311         mergedProjectMetadata: Provider<RegularFile>
312     ): TaskProvider<MergeMultiplatformMetadataTask> {
313         val tempMultiplatformMetadataDirectory =
314             project.layout.buildDirectory.dir("tmp/multiplatformMetadataFiles")
315         // unzip the sources into source folder and metadata files into folders per project
316         val unzipMultiplatformSources =
317             project.tasks.register(
318                 "unzipMultiplatformSources",
319                 UnzipMultiplatformSourcesTask::class.java
320             ) {
321                 it.inputJars.set(
322                     multiplatformDocsSourcesConfiguration.incoming.artifactView {}.files
323                 )
324                 it.metadataOutput.set(tempMultiplatformMetadataDirectory)
325                 it.sourceOutput.set(unzippedMultiplatformSourcesDirectory)
326                 it.samplesOutput.set(unzippedMultiplatformSamplesDirectory)
327             }
328         // merge all the metadata files from the individual project dirs
329         return project.tasks.register(
330             "mergeMultiplatformMetadata",
331             MergeMultiplatformMetadataTask::class.java
332         ) {
333             it.mergedProjectMetadata.set(mergedProjectMetadata)
334             it.inputDirectory.set(unzipMultiplatformSources.flatMap { it.metadataOutput })
335         }
336     }
337 
338     /**
339      * The following configurations are created to build a list of projects that need to be
340      * documented and should be used from build.gradle of docs projects for the following:
341      * - docs(project(":foo:foo") or docs("androidx.foo:foo:1.0.0") for docs sources
342      * - samples(project(":foo:foo-samples") or samples("androidx.foo:foo-samples:1.0.0") for
343      *   samples sources
344      * - stubs(project(":foo:foo-stubs")) - stubs needed for a documented library
345      */
346     private fun createConfigurations(project: Project) {
347         project.dependencies.components.all<SourcesVariantRule>()
348         val docsConfiguration =
349             project.configurations.create("docs") {
350                 it.isCanBeResolved = false
351                 it.isCanBeConsumed = false
352             }
353         // This exists for libraries that are deprecated or not hosted in the AndroidX repo
354         val docsWithoutApiSinceConfiguration =
355             project.configurations.create("docsWithoutApiSince") {
356                 it.isCanBeResolved = false
357                 it.isCanBeConsumed = false
358             }
359         val multiplatformDocsConfiguration =
360             project.configurations.create("kmpDocs") {
361                 it.isCanBeResolved = false
362                 it.isCanBeConsumed = false
363             }
364         val samplesConfiguration =
365             project.configurations.create("samples") {
366                 it.isCanBeResolved = false
367                 it.isCanBeConsumed = false
368             }
369         val stubsConfiguration =
370             project.configurations.create("stubs") {
371                 it.isCanBeResolved = false
372                 it.isCanBeConsumed = false
373             }
374 
375         fun Configuration.setResolveSources() {
376             isTransitive = false
377             isCanBeConsumed = false
378             attributes {
379                 it.attribute(
380                     Usage.USAGE_ATTRIBUTE,
381                     project.objects.named<Usage>(Usage.JAVA_RUNTIME)
382                 )
383                 it.attribute(
384                     Category.CATEGORY_ATTRIBUTE,
385                     project.objects.named<Category>(Category.DOCUMENTATION)
386                 )
387                 it.attribute(
388                     DocsType.DOCS_TYPE_ATTRIBUTE,
389                     project.objects.named<DocsType>(DocsType.SOURCES)
390                 )
391                 it.attribute(
392                     LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
393                     project.objects.named<LibraryElements>(LibraryElements.JAR)
394                 )
395             }
396         }
397         docsSourcesConfiguration =
398             project.configurations.create("docs-sources") {
399                 it.setResolveSources()
400                 it.extendsFrom(docsConfiguration, docsWithoutApiSinceConfiguration)
401             }
402         multiplatformDocsSourcesConfiguration =
403             project.configurations.create("multiplatform-docs-sources") { configuration ->
404                 configuration.isTransitive = false
405                 configuration.isCanBeConsumed = false
406                 configuration.attributes {
407                     it.attribute(Usage.USAGE_ATTRIBUTE, project.multiplatformUsage)
408                     it.attribute(
409                         Category.CATEGORY_ATTRIBUTE,
410                         project.objects.named<Category>(Category.DOCUMENTATION)
411                     )
412                     it.attribute(
413                         DocsType.DOCS_TYPE_ATTRIBUTE,
414                         project.objects.named<DocsType>(DocsType.SOURCES)
415                     )
416                     it.attribute(
417                         LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
418                         project.objects.named<LibraryElements>(LibraryElements.JAR)
419                     )
420                 }
421                 configuration.extendsFrom(multiplatformDocsConfiguration)
422             }
423         samplesSourcesConfiguration =
424             project.configurations.create("samples-sources") {
425                 it.setResolveSources()
426                 it.extendsFrom(samplesConfiguration)
427             }
428 
429         versionMetadataConfiguration =
430             project.configurations.create("library-version-metadata") {
431                 it.isTransitive = false
432                 it.isCanBeConsumed = false
433 
434                 it.attributes.attribute(Usage.USAGE_ATTRIBUTE, project.versionMetadataUsage)
435                 it.attributes.attribute(
436                     Category.CATEGORY_ATTRIBUTE,
437                     project.objects.named<Category>(Category.DOCUMENTATION)
438                 )
439                 it.attributes.attribute(
440                     Bundling.BUNDLING_ATTRIBUTE,
441                     project.objects.named<Bundling>(Bundling.EXTERNAL)
442                 )
443 
444                 it.extendsFrom(docsConfiguration, multiplatformDocsConfiguration)
445             }
446 
447         fun Configuration.setResolveClasspathForUsage(usage: String) {
448             isCanBeConsumed = false
449             attributes {
450                 it.attribute(Usage.USAGE_ATTRIBUTE, project.objects.named<Usage>(usage))
451                 it.attribute(
452                     Category.CATEGORY_ATTRIBUTE,
453                     project.objects.named<Category>(Category.LIBRARY)
454                 )
455                 it.attribute(
456                     BuildTypeAttr.ATTRIBUTE,
457                     project.objects.named<BuildTypeAttr>("release")
458                 )
459             }
460             extendsFrom(
461                 docsConfiguration,
462                 samplesConfiguration,
463                 stubsConfiguration,
464                 docsWithoutApiSinceConfiguration
465             )
466         }
467 
468         // Build a compile & runtime classpaths for needed for documenting the libraries
469         // from the configurations above.
470         val docsCompileClasspath =
471             project.configurations.create("docs-compile-classpath") {
472                 it.setResolveClasspathForUsage(Usage.JAVA_API)
473             }
474         val docsRuntimeClasspath =
475             project.configurations.create("docs-runtime-classpath") {
476                 it.setResolveClasspathForUsage(Usage.JAVA_RUNTIME)
477             }
478         // TODO: Set to DEFAULT when b/407754247 is fixed
479         val kotlinDefaultCatalogVersion = androidx.build.KotlinTarget.KOTLIN_1_8.catalogVersion
480         val kotlinLatest = project.versionCatalog.findVersion(kotlinDefaultCatalogVersion).get()
481         listOf(docsCompileClasspath, docsRuntimeClasspath).forEach { config ->
482             config.resolutionStrategy {
483                 it.eachDependency { details ->
484                     if (details.requested.group == "org.jetbrains.kotlin") {
485                         details.useVersion(kotlinLatest.requiredVersion)
486                     }
487                 }
488             }
489         }
490         dependencyClasspath =
491             docsCompileClasspath.incoming
492                 .artifactView {
493                     it.attributes.attribute(
494                         Attribute.of("artifactType", String::class.java),
495                         "android-classes"
496                     )
497                 }
498                 .files +
499                 docsRuntimeClasspath.incoming
500                     .artifactView {
501                         it.attributes.attribute(
502                             Attribute.of("artifactType", String::class.java),
503                             "android-classes"
504                         )
505                     }
506                     .files
507     }
508 
509     private fun configureDackka(
510         project: Project,
511         unzippedJvmSourcesDirectory: Provider<Directory>,
512         unzippedMultiplatformSourcesDirectory: Provider<Directory>,
513         unzipJvmSourcesTask: TaskProvider<Sync>,
514         configureMultiplatformSourcesTask: TaskProvider<MergeMultiplatformMetadataTask>,
515         unzippedDeprecatedSamplesSources: Provider<Directory>,
516         unzipDeprecatedSamplesTask: TaskProvider<Sync>,
517         unzippedJvmSamplesSources: Provider<Directory>,
518         unzipJvmSamplesTask: TaskProvider<Sync>,
519         unzippedKmpSamplesSources: Provider<Directory>,
520         dependencyClasspath: FileCollection,
521         buildOnServer: TaskProvider<*>,
522         docsConfiguration: Configuration,
523         multiplatformDocsConfiguration: Configuration,
524         mergedProjectMetadata: Provider<RegularFile>
525     ) {
526         val generatedDocsDir = project.layout.buildDirectory.dir("docs")
527 
528         val dackkaConfiguration =
529             project.configurations.create("dackka") {
530                 it.dependencies.add(project.dependencies.create(project.getLibraryByName("dackka")))
531                 it.isCanBeConsumed = false
532             }
533 
534         val generateMetadataTask =
535             project.tasks.register("generateMetadata", GenerateMetadataTask::class.java) { task ->
536                 val artifacts = docsConfiguration.incoming.artifacts.resolvedArtifacts
537                 task.getArtifactIds().set(artifacts.map { result -> result.map { it.id } })
538                 task.getArtifactFiles().set(artifacts.map { result -> result.map { it.file } })
539                 val multiplatformArtifacts =
540                     multiplatformDocsConfiguration.incoming.artifacts.resolvedArtifacts
541                 task
542                     .getMultiplatformArtifactIds()
543                     .set(multiplatformArtifacts.map { result -> result.map { it.id } })
544                 task
545                     .getMultiplatformArtifactFiles()
546                     .set(multiplatformArtifacts.map { result -> result.map { it.file } })
547                 task.destinationFile.set(getMetadataRegularFile(project))
548             }
549 
550         val metricsFile = project.layout.buildDirectory.file("build-metrics.json")
551         val projectName = project.name
552 
553         val dackkaTask =
554             project.tasks.register("docs", DackkaTask::class.java) { task ->
555                 var taskStartTime: LocalDateTime? = null
556                 task.argsJsonFile.set(
557                     File(project.getDistributionDirectory(), "dackkaArgs-${project.name}.json")
558                 )
559                 task.apply {
560                     // Remove once there is property version of Copy#destinationDir
561                     // Use samplesDir.set(unzipSamplesTask.flatMap { it.destinationDirectory })
562                     // https://github.com/gradle/gradle/issues/25824
563                     dependsOn(unzipJvmSourcesTask)
564                     dependsOn(unzipJvmSamplesTask)
565                     dependsOn(unzipDeprecatedSamplesTask)
566                     dependsOn(configureMultiplatformSourcesTask)
567 
568                     description =
569                         "Generates reference documentation using a Google devsite Dokka" +
570                             " plugin. Places docs in ${generatedDocsDir.get()}"
571                     group = JavaBasePlugin.DOCUMENTATION_GROUP
572 
573                     dackkaClasspath.from(project.files(dackkaConfiguration))
574                     destinationDir.set(generatedDocsDir)
575                     frameworkSamplesDir.set(File(project.getSupportRootFolder(), "samples"))
576                     samplesDeprecatedDir.set(unzippedDeprecatedSamplesSources)
577                     samplesJvmDir.set(unzippedJvmSamplesSources)
578                     samplesKmpDir.set(unzippedKmpSamplesSources)
579                     jvmSourcesDir.set(unzippedJvmSourcesDirectory)
580                     multiplatformSourcesDir.set(unzippedMultiplatformSourcesDirectory)
581                     projectListsDirectory.set(
582                         File(project.getSupportRootFolder(), "docs-public/package-lists")
583                     )
584                     dependenciesClasspath.from(
585                         dependencyClasspath +
586                             project.getAndroidJar(
587                                 project.defaultAndroidConfig.latestStableCompileSdk
588                             ) +
589                             project.getExtraCommonDependencies()
590                     )
591                     excludedPackages.set(hiddenPackages.toSet())
592                     excludedPackagesForJava.set(hiddenPackagesJava)
593                     excludedPackagesForKotlin.set(emptySet())
594                     libraryMetadataFile.set(generateMetadataTask.flatMap { it.destinationFile })
595                     projectStructureMetadataFile.set(mergedProjectMetadata)
596                     // See go/dackka-source-link for details on these links.
597                     baseSourceLink.set("https://cs.android.com/search?q=file:%s+class:%s")
598                     baseFunctionSourceLink.set(
599                         "https://cs.android.com/search?q=file:%s+function:%s"
600                     )
601                     basePropertySourceLink.set("https://cs.android.com/search?q=file:%s+symbol:%s")
602                     annotationsNotToDisplay.set(hiddenAnnotations)
603                     annotationsNotToDisplayJava.set(hiddenAnnotationsJava)
604                     annotationsNotToDisplayKotlin.set(hiddenAnnotationsKotlin)
605                     hidingAnnotations.set(annotationsToHideApis)
606                     nullabilityAnnotations.set(validNullabilityAnnotations)
607                     versionMetadataFiles.from(
608                         versionMetadataConfiguration.incoming.artifactView {}.files
609                     )
610                     task.doFirst { taskStartTime = LocalDateTime.now() }
611                     task.doLast {
612                         val cpus =
613                             try {
614                                 ProcessBuilder("lscpu")
615                                     .start()
616                                     .apply { waitFor(100L, TimeUnit.MILLISECONDS) }
617                                     .inputStream
618                                     .bufferedReader()
619                                     .readLines()
620                                     .filter { it.startsWith("CPU(s):") }
621                                     .singleOrNull()
622                                     ?.split(" ")
623                                     ?.last()
624                                     ?.toInt()
625                             } catch (e: java.io.IOException) {
626                                 null
627                             } // not running on linux
628                         if (cpus != 64) { // Keep stddev of build metrics low b/334867245
629                             println("$cpus cpus, so not storing build metrics.")
630                             return@doLast
631                         }
632                         println("$cpus cpus, so storing build metrics.")
633                         val taskEndTime = LocalDateTime.now()
634                         val duration = Duration.between(taskStartTime, taskEndTime).toMillis()
635                         metricsFile
636                             .get()
637                             .asFile
638                             .writeText("{ \"${projectName}_docs_execution_duration\": $duration }")
639                     }
640                 }
641             }
642 
643         val zipTask =
644             project.tasks.register("zipDocs", Zip::class.java) { task ->
645                 task.apply {
646                     from(dackkaTask.flatMap { it.destinationDir })
647 
648                     val baseName = "docs-$docsType"
649                     val buildId = getBuildId()
650                     archiveBaseName.set(baseName)
651                     archiveVersion.set(buildId)
652                     destinationDirectory.set(project.getDistributionDirectory())
653                     group = JavaBasePlugin.DOCUMENTATION_GROUP
654 
655                     val filePath = "${project.getDistributionDirectory().canonicalPath}/"
656                     val fileName = "$baseName-$buildId.zip"
657                     val destinationFile = filePath + fileName
658                     description =
659                         "Zips Java and Kotlin documentation (generated via Dackka in the" +
660                             " style of d.android.com) into $destinationFile"
661                 }
662             }
663         buildOnServer.configure { it.dependsOn(zipTask) }
664     }
665 
666     /**
667      * Replace all tests etc with empty task, so we don't run anything it is more effective then
668      * task.enabled = false, because we avoid executing deps as well
669      */
670     private fun disableUnneededTasks(project: Project) {
671         var reentrance = false
672         project.tasks.whenTaskAdded { task ->
673             if (
674                 task is Test ||
675                     task.name.startsWith("assemble") ||
676                     task.name == "lint" ||
677                     task.name == "lintDebug" ||
678                     task.name == "lintAnalyzeDebug" ||
679                     task.name == "transformDexArchiveWithExternalLibsDexMergerForPublicDebug" ||
680                     task.name == "transformResourcesWithMergeJavaResForPublicDebug" ||
681                     task.name == "checkPublicDebugDuplicateClasses"
682             ) {
683                 if (!reentrance) {
684                     reentrance = true
685                     project.tasks.named(task.name) {
686                         it.actions = emptyList()
687                         it.dependsOn(emptyList<Task>())
688                     }
689                     reentrance = false
690                 }
691             }
692         }
693     }
694 }
695 
696 @DisableCachingByDefault(because = "Doesn't benefit from caching")
697 open class DocsBuildOnServer : DefaultTask() {
698     @Internal lateinit var docsType: String
699     @Internal lateinit var buildId: String
700     @Internal lateinit var distributionDirectory: File
701 
702     @[InputFiles PathSensitive(PathSensitivity.RELATIVE)]
getRequiredFilesnull703     fun getRequiredFiles(): List<File> {
704         return listOf(
705             File(distributionDirectory, "docs-$docsType-$buildId.zip"),
706         )
707     }
708 
709     @TaskAction
checkAllBuildOutputsnull710     fun checkAllBuildOutputs() {
711         val missingFiles = mutableListOf<String>()
712         getRequiredFiles().forEach { file ->
713             if (!file.exists()) {
714                 missingFiles.add(file.path)
715             }
716         }
717 
718         if (missingFiles.isNotEmpty()) {
719             val missingFileString = missingFiles.reduce { acc, s -> "$acc, $s" }
720             throw FileNotFoundException("buildOnServer required output missing: $missingFileString")
721         }
722     }
723 }
724 
725 /**
726  * Adapter rule to handles prebuilt dependencies that do not use Gradle Metadata (only pom). We
727  * create a new variant sources that we can later use in the same way we do for tip of tree projects
728  * and prebuilts with Gradle Metadata.
729  */
730 abstract class SourcesVariantRule : ComponentMetadataRule {
731     @get:Inject abstract val objects: ObjectFactory
732 
executenull733     override fun execute(context: ComponentMetadataContext) {
734         context.details.maybeAddVariant("sources", "runtime") {
735             it.attributes {
736                 it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
737                 it.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION))
738                 it.attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named(DocsType.SOURCES))
739             }
740             it.withFiles {
741                 it.removeAllFiles()
742                 it.addFile("${context.details.id.name}-${context.details.id.version}-sources.jar")
743             }
744         }
745     }
746 }
747 
748 /**
749  * Location of the library metadata JSON file that's used by Dackka, represented as a [RegularFile]
750  */
getMetadataRegularFilenull751 private fun getMetadataRegularFile(project: Project): Provider<RegularFile> =
752     project.layout.buildDirectory.file("AndroidXLibraryMetadata.json")
753 
754 // List of packages to exclude from both Java and Kotlin refdoc generation
755 private val hiddenPackages =
756     listOf(
757         "androidx.camera.camera2.impl",
758         "androidx.camera.camera2.internal.*",
759         "androidx.camera.core.impl.*",
760         "androidx.camera.core.internal.*",
761         "androidx.core.internal",
762         "androidx.preference.internal",
763         "androidx.wear.internal.widget.drawer",
764         "androidx.webkit.internal",
765         "androidx.work.impl.*"
766     )
767 
768 // Set of packages to exclude from Java refdoc generation
769 private val hiddenPackagesJava =
770     setOf(
771         "androidx.*compose.*",
772         "androidx.*glance.*",
773         "androidx\\.tv\\..*",
774     )
775 
776 // List of annotations which should not be displayed in the docs
777 private val hiddenAnnotations: List<String> =
778     listOf(
779         // This information is compose runtime implementation details; not useful for most, those
780         // who
781         // would want it should look at source
782         "androidx.compose.runtime.Stable",
783         "androidx.compose.runtime.Immutable",
784         "androidx.compose.runtime.ReadOnlyComposable",
785         // This opt-in requirement is non-propagating so developers don't need to know about it
786         // https://kotlinlang.org/docs/opt-in-requirements.html#non-propagating-opt-in
787         "androidx.annotation.OptIn",
788         "kotlin.OptIn",
789         // This annotation is used mostly in paging, and was removed at the request of the paging
790         // team
791         "androidx.annotation.CheckResult",
792         // This annotation is generated upstream. Dokka uses it for signature serialization. It
793         // doesn't
794         // seem useful for developers
795         "kotlin.ParameterName",
796         // This annotations is not useful for developers but right now is @ShowAnnotation?
797         "kotlin.js.JsName",
798         // This annotation is intended to target the compiler and is general not useful for devs.
799         "java.lang.Override",
800         // This annotation is used by the room processor and isn't useful for developers
801         "androidx.room.Ignore"
802     )
803 
804 val validNullabilityAnnotations =
805     listOf(
806         "org.jspecify.annotations.NonNull",
807         "org.jspecify.annotations.Nullable",
808         "androidx.annotation.Nullable",
809         "android.annotation.Nullable",
810         "androidx.annotation.NonNull",
811         "android.annotation.NonNull",
812         // Required by media3
813         "org.checkerframework.checker.nullness.qual.Nullable",
814     )
815 
816 // Annotations which should not be displayed in the Kotlin docs, in addition to hiddenAnnotations
817 private val hiddenAnnotationsKotlin: List<String> = listOf("kotlin.ExtensionFunctionType")
818 
819 // Annotations which should not be displayed in the Java docs, in addition to hiddenAnnotations
820 private val hiddenAnnotationsJava: List<String> = emptyList()
821 
822 // Annotations which mean the elements they are applied to should be hidden from the docs
823 private val annotationsToHideApis: List<String> =
824     listOf(
825         "androidx.annotation.RestrictTo",
826         // Appears in androidx.test sources
827         "dagger.internal.DaggerGenerated",
828     )
829 
830 /** Data class that matches JSON structure of kotlin source set metadata */
831 data class ProjectStructureMetadata(var sourceSets: List<SourceSetMetadata>)
832 
833 data class SourceSetMetadata(
834     val name: String,
835     val analysisPlatform: String,
836     var dependencies: List<String>
837 )
838 
839 @CacheableTask
840 abstract class UnzipMultiplatformSourcesTask() : DefaultTask() {
841 
842     @get:Classpath abstract val inputJars: Property<FileCollection>
843 
844     @get:OutputDirectory abstract val metadataOutput: DirectoryProperty
845 
846     @get:OutputDirectory abstract val sourceOutput: DirectoryProperty
847 
848     @get:OutputDirectory abstract val samplesOutput: DirectoryProperty
849 
850     @get:Inject abstract val fileSystemOperations: FileSystemOperations
851 
852     @get:Inject abstract val archiveOperations: ArchiveOperations
853 
854     @get:Inject abstract val projectLayout: ProjectLayout
855 
856     @TaskAction
857     fun execute() {
858         val tempSourcesDir =
859             projectLayout.buildDirectory.dir("tmp/MultiplatformSources").get().asFile
860         val tempSamplesDir =
861             projectLayout.buildDirectory.dir("tmp/MultiplatformSamples").get().asFile
862         // Get rid of stale files in the directories
863         tempSourcesDir.deleteRecursively()
864         tempSamplesDir.deleteRecursively()
865 
866         val (sources, samples) =
867             inputJars
868                 .get()
869                 .associate { it.name to archiveOperations.zipTree(it) }
870                 .toSortedMap()
871                 // Now that we publish sample jars, they can get confused with normal source
872                 // jars. We want to handle sample jars separately, so filter by the name.
873                 .partition { name -> "samples" !in name }
874 
875         sources.values.forEach { fileTree ->
876             fileTree.matching { it.exclude("META-INF/*") }.rewriteImageTagsAndCopy(tempSourcesDir)
877         }
878         fileSystemOperations.sync {
879             it.duplicatesStrategy = DuplicatesStrategy.FAIL
880             it.from(tempSourcesDir)
881             it.into(sourceOutput)
882             it.exclude("META-INF/*")
883         }
884 
885         samples.values.forEach { fileTree ->
886             fileTree.matching { it.exclude("META-INF/*") }.rewriteImageTagsAndCopy(tempSamplesDir)
887         }
888         fileSystemOperations.sync {
889             // Some libraries share samples, e.g. paging. This can be an issue if and only if the
890             // consumer libraries have pinned samples version or are not in an atomic group.
891             // We don't have anything matching this case now, but should enforce better. b/334825580
892             it.duplicatesStrategy = DuplicatesStrategy.INCLUDE
893             it.from(tempSamplesDir)
894             it.into(samplesOutput)
895             it.exclude("META-INF/*")
896         }
897         sources.forEach { (name, fileTree) ->
898             fileSystemOperations.sync {
899                 it.from(fileTree)
900                 it.into(metadataOutput.file(name))
901                 it.include("META-INF/*")
902             }
903         }
904     }
905 }
906 
partitionnull907 private fun <K, V> Map<K, V>.partition(condition: (K) -> Boolean): Pair<Map<K, V>, Map<K, V>> =
908     this.toList().partition { (k, _) -> condition(k) }.let { it.first.toMap() to it.second.toMap() }
909 
910 /** Merges multiplatform metadata files created by [CreateMultiplatformMetadata] */
911 @CacheableTask
912 abstract class MergeMultiplatformMetadataTask : DefaultTask() {
913 
914     @get:InputFiles
915     @get:PathSensitive(PathSensitivity.RELATIVE)
916     abstract val inputDirectory: DirectoryProperty
917     @get:OutputFile abstract val mergedProjectMetadata: RegularFileProperty
918 
919     @TaskAction
executenull920     fun execute() {
921         val mergedMetadata = ProjectStructureMetadata(sourceSets = listOf())
922         inputDirectory
923             .get()
924             .asFile
925             .walkTopDown()
926             .filter { file -> file.name == PROJECT_STRUCTURE_METADATA_FILENAME }
927             .forEach { metaFile ->
928                 val gson = GsonBuilder().create()
929                 val metadata =
930                     gson.fromJson(metaFile.readText(), ProjectStructureMetadata::class.java)
931                 mergedMetadata.merge(metadata)
932             }
933         val gson = GsonBuilder().setPrettyPrinting().create()
934         // Sort sourceSets to ensure that child sourceSets come after their parents, b/404784813
935         // Also ensure deterministic order--mergedMetadata.merge() uses .toSet() to deduplicate.
936         mergedMetadata.sourceSets =
937             mergedMetadata.sourceSets.sortedWith(compareBy({ it.dependencies.size }, { it.name }))
938         val json = gson.toJson(mergedMetadata)
939         mergedProjectMetadata.get().asFile.apply {
940             parentFile.mkdirs()
941             createNewFile()
942             writeText(json)
943         }
944     }
945 
mergenull946     private fun ProjectStructureMetadata.merge(metadata: ProjectStructureMetadata) {
947         val originalSourceSets = this.sourceSets
948         metadata.sourceSets.forEach { newSourceSet ->
949             val existingSourceSet = originalSourceSets.find { it.name == newSourceSet.name }
950             if (existingSourceSet != null) {
951                 existingSourceSet.dependencies =
952                     (newSourceSet.dependencies + existingSourceSet.dependencies).toSet().toList()
953             } else {
954                 sourceSets += listOf(newSourceSet)
955             }
956         }
957     }
958 }
959 
Projectnull960 private fun Project.getPrebuiltsExternalPath() =
961     File(project.getCheckoutRoot(), "prebuilts/androidx/external/")
962 
963 private val PLATFORMS =
964     listOf("linuxx64", "macosarm64", "macosx64", "iosx64", "iossimulatorarm64", "iosarm64")
965 
966 private fun Project.getExtraCommonDependencies(): FileCollection =
967     files(
968         arrayOf(
969             File(
970                 getPrebuiltsExternalPath(),
971                 "org/jetbrains/kotlinx/kotlinx-coroutines-core/1.6.4/" +
972                     "kotlinx-coroutines-core-1.6.4.jar"
973             ),
974             File(
975                 getPrebuiltsExternalPath(),
976                 "org/jetbrains/kotlinx/atomicfu/0.17.0/atomicfu-0.17.0.jar"
977             ),
978             File(getPrebuiltsExternalPath(), "com/squareup/okio/okio-jvm/3.1.0/okio-jvm-3.1.0.jar")
979         ) +
980             PLATFORMS.map {
981                 File(
982                     getPrebuiltsExternalPath(),
983                     "com/squareup/okio/okio-$it/3.1.0/okio-$it-3.1.0.klib"
984                 )
985             }
986     )
987 
FileTreenull988 private fun FileTree.rewriteImageTagsAndCopy(destinationDir: File) {
989     visit { fileDetails ->
990         if (!fileDetails.isDirectory) {
991             val targetFile = File(destinationDir, fileDetails.relativePath.pathString)
992             targetFile.parentFile.mkdirs()
993 
994             if (fileDetails.file.extension == "kt") {
995                 val content = fileDetails.file.readText()
996                 val updatedContent = rewriteLinks(content)
997                 targetFile.writeText(updatedContent)
998             } else {
999                 fileDetails.file.copyTo(targetFile, overwrite = true)
1000             }
1001         }
1002     }
1003 }
1004 
1005 /**
1006  * Rewrites multi-line markdown links ![]()to a single-line format. Work-around for b/350055200.
1007  *
1008  * Example transformation:
1009  * ```
1010  * Original: ![Example
1011  *           Image](http://example.com/image.png)
1012  * Result:   ![Example Image](http://example.com/image.png)
1013  * ```
1014  */
rewriteLinksnull1015 internal fun rewriteLinks(content: String): String =
1016     markdownLinksRegex.replace(content) { matchResult ->
1017         val exclamationMark = matchResult.groupValues[1]
1018         val linkText = matchResult.groupValues[2].replace("\n", " ").replace(" * ", "").trim()
1019         val url = matchResult.groupValues[3].trim()
1020         "$exclamationMark[$linkText]($url)"
1021     }
1022 
1023 /**
1024  * Regular expression to match markdown link syntax, supporting both standard links and image links.
1025  *
1026  * The pattern matches:
1027  * - Optional `!` at the beginning for image links.
1028  * - Link text enclosed in square brackets `[ ... ]`, allowing for whitespace around the text.
1029  * - URL in parentheses `( ... )`, allowing for whitespace around the URL.
1030  */
1031 private val markdownLinksRegex = Regex("""(!?)\[\s*([^\[\]]+?)\s*]\(\s*([^(]+?)\s*\)""")
1032