Improve third-party license display.
This commit is contained in:
parent
be8742f69e
commit
901063f4c9
6 changed files with 4876 additions and 4062 deletions
|
@ -11,6 +11,7 @@ plugins {
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'com.squareup.wire'
|
id 'com.squareup.wire'
|
||||||
id 'translations'
|
id 'translations'
|
||||||
|
id 'licenses'
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: 'static-ips.gradle'
|
apply from: 'static-ips.gradle'
|
||||||
|
|
File diff suppressed because it is too large
Load diff
2301
build-logic/plugins/src/main/java/Licenses.kt
Normal file
2301
build-logic/plugins/src/main/java/Licenses.kt
Normal file
File diff suppressed because it is too large
Load diff
219
build-logic/plugins/src/main/java/licenses.gradle.kts
Normal file
219
build-logic/plugins/src/main/java/licenses.gradle.kts
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Licenses.LicenseData
|
||||||
|
import groovy.xml.XmlSlurper
|
||||||
|
import groovy.xml.slurpersupport.GPathResult
|
||||||
|
import org.gradle.internal.logging.text.StyledTextOutput
|
||||||
|
import org.gradle.internal.logging.text.StyledTextOutputFactory
|
||||||
|
import org.gradle.kotlin.dsl.support.serviceOf
|
||||||
|
import org.xml.sax.helpers.DefaultHandler
|
||||||
|
|
||||||
|
val out = project.serviceOf<StyledTextOutputFactory>().create("output")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the licenses for all of our dependencies and saves them to app/src/main/res/raw/third_party_licenses.
|
||||||
|
*
|
||||||
|
* This task will fail if we cannot map an artifact's license to a known license in [Licenses]. If this happens,
|
||||||
|
* you need to manually save the new license, or map the URL to an existing license in [Licenses.getLicense].
|
||||||
|
*/
|
||||||
|
task("saveLicenses") {
|
||||||
|
description = "Finds the licenses for all of our dependencies and saves them to app/src/main/res/raw/third_party_licenses."
|
||||||
|
group = "Static Files"
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
// We resolve all the artifacts, map them to a dependency that lets us fetch the POM metadata, and then use that to generate our models
|
||||||
|
val resolvedDependencies: List<ResolvedDependency> = configurations
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isCanBeResolved }
|
||||||
|
.mapNotNull { it.tryResolveConfiguration() }
|
||||||
|
.mapNotNull { it.tryToResolveArtifacts() }
|
||||||
|
.flatten()
|
||||||
|
.distinctBy { it.file }
|
||||||
|
.map { pomDependency(it.id.componentIdentifier.toString()) }
|
||||||
|
.mapNotNull { it.toResolvedDependency() }
|
||||||
|
.filter { it.licenses.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
// Next we want to map each dependency to a known license, failing if we can't do so
|
||||||
|
val licenseToDependencies: MutableMap<LicenseData, MutableList<ResolvedDependency>> = mutableMapOf()
|
||||||
|
|
||||||
|
for (resolvedDependency in resolvedDependencies) {
|
||||||
|
for (license in resolvedDependency.licenses) {
|
||||||
|
val licenseData: LicenseData? = Licenses.getLicense(license.url)
|
||||||
|
|
||||||
|
if (licenseData == null) {
|
||||||
|
printlnError("Failed to find matching license data for ${license.name} (${license.url}), which is in use by $resolvedDependency")
|
||||||
|
throw RuntimeException("Failed to find matching license data! See output for details.")
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseToDependencies.getOrPut(licenseData) { mutableListOf() } += resolvedDependency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we can build the actual string that we'll write to the file
|
||||||
|
val output = StringBuilder()
|
||||||
|
|
||||||
|
licenseToDependencies
|
||||||
|
.entries
|
||||||
|
.sortedByDescending { it.value.size }
|
||||||
|
.forEach { entry ->
|
||||||
|
val license: LicenseData = entry.key
|
||||||
|
|
||||||
|
// Some companies have multiple artifacts that are named identically (and licensed identically).
|
||||||
|
// This just dedupes it based on the name+url so that you don't see the same name in the list twice.
|
||||||
|
val dependencies: List<ResolvedDependency> = entry.value.distinctBy { it.name + it.url }.sortedBy { it.name }
|
||||||
|
|
||||||
|
output.append("The following dependencies are licensed under ${license.name}:\n\n")
|
||||||
|
for (dependency in dependencies) {
|
||||||
|
output.append("* ${dependency.name}")
|
||||||
|
if (dependency.url != null) {
|
||||||
|
output.append(" (${dependency.url})")
|
||||||
|
}
|
||||||
|
output.append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
output.append("\n")
|
||||||
|
output.append("==========================================================\n")
|
||||||
|
output.append("==========================================================\n")
|
||||||
|
output.append(license.text)
|
||||||
|
output.append("\n")
|
||||||
|
output.append("==========================================================\n")
|
||||||
|
output.append("==========================================================\n")
|
||||||
|
output.append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the file to disk
|
||||||
|
rootProject
|
||||||
|
.file("app/src/main/res/raw/third_party_licenses")
|
||||||
|
.writeText(output.toString())
|
||||||
|
|
||||||
|
printlnSuccess("Done!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a dependency for a POM to a [ResolvedDependency], which is just our nice and usable internal representation of all the data we need.
|
||||||
|
*
|
||||||
|
* @param leafDependency The actual dependency we're trying to resolve. If present, it means that [this] refers to a _parent_ of that dependency, which we're
|
||||||
|
* looking at just to try to get licensing information.
|
||||||
|
*/
|
||||||
|
fun Dependency.toResolvedDependency(leafDependency: ResolvedDependency? = null): ResolvedDependency? {
|
||||||
|
val pomConfiguration: Configuration = project.configurations.detachedConfiguration(this)
|
||||||
|
val pomFile: File = try {
|
||||||
|
pomConfiguration.resolve().first()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
printlnWarning("[${this.id}] Failed to resolve the POM dependency to a file.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val xmlParser = XmlSlurper(true, false).apply {
|
||||||
|
errorHandler = DefaultHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
val xml: GPathResult = xmlParser.parse(pomFile)
|
||||||
|
|
||||||
|
val licenses: List<RawLicense> = try {
|
||||||
|
xml
|
||||||
|
.get("licenses")
|
||||||
|
?.get("license")
|
||||||
|
?.map { it as GPathResult }
|
||||||
|
?.map {
|
||||||
|
RawLicense(
|
||||||
|
name = it.get("name")?.text()?.trim() ?: "",
|
||||||
|
url = it.get("url")?.text()?.trim() ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?.filter {
|
||||||
|
it.name.isNotEmpty() && it.url.isNotEmpty()
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
printlnWarning("[${this.id}] Error when parsing XML")
|
||||||
|
e.printStackTrace()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a leafDependency, we just want to copy the possibly-found license into it, leaving the metadata alone, since that's the actual target of
|
||||||
|
// our search
|
||||||
|
val resolvedDependency = if (leafDependency != null) {
|
||||||
|
leafDependency.copy(licenses = licenses)
|
||||||
|
} else {
|
||||||
|
ResolvedDependency(
|
||||||
|
id = this.id,
|
||||||
|
name = xml.get("name")?.text()?.ifBlank { null } ?: xml.get("artifactId")?.text()?.ifBlank { null } ?: this.name,
|
||||||
|
url = xml.get("url")?.text()?.ifBlank { null },
|
||||||
|
licenses = licenses
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no licenses, but a parent exists, then we can walk up the tree and try to find a license in a parent
|
||||||
|
if (resolvedDependency.licenses.isEmpty()) {
|
||||||
|
val parentGroup: String? = xml.get("parent")?.get("groupId")?.text()?.trim()
|
||||||
|
val parentName: String? = xml.get("parent")?.get("artifactId")?.text()?.trim()
|
||||||
|
val parentVersion: String? = xml.get("parent")?.get("version")?.text()?.trim()
|
||||||
|
|
||||||
|
if (parentGroup != null && parentName != null && parentVersion != null) {
|
||||||
|
printlnNormal("[${this.id}] Could not find a license on this node. Checking the parent.")
|
||||||
|
return pomDependency("$parentGroup:$parentName:$parentVersion").toResolvedDependency(leafDependency = resolvedDependency)
|
||||||
|
}
|
||||||
|
} else if (leafDependency != null) {
|
||||||
|
printlnNormal("[${leafDependency.id}] Found a license on a parent dependency. (parent = ${this.id})")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedDependency
|
||||||
|
}
|
||||||
|
|
||||||
|
fun printlnNormal(message: String) {
|
||||||
|
out.style(StyledTextOutput.Style.Normal).println(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun printlnWarning(message: String) {
|
||||||
|
out.style(StyledTextOutput.Style.Description).println(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun printlnSuccess(message: String) {
|
||||||
|
out.style(StyledTextOutput.Style.SuccessHeader).println(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun printlnError(message: String) {
|
||||||
|
out.style(StyledTextOutput.Style.FailureHeader).println(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pomDependency(locator: String): Dependency {
|
||||||
|
return project.dependencies.create("$locator@pom")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Configuration.tryResolveConfiguration(): ResolvedConfiguration? {
|
||||||
|
return try {
|
||||||
|
this.resolvedConfiguration
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ResolvedConfiguration.tryToResolveArtifacts(): Set<ResolvedArtifact>? {
|
||||||
|
return try {
|
||||||
|
this.resolvedArtifacts
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun GPathResult.get(key: String): GPathResult? {
|
||||||
|
return this.getProperty(key) as? GPathResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val Dependency.id: String
|
||||||
|
get() = "${this.group}:${this.name}:${this.version}"
|
||||||
|
|
||||||
|
data class ResolvedDependency(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val url: String?,
|
||||||
|
val licenses: List<RawLicense>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RawLicense(val name: String, val url: String)
|
|
@ -30,7 +30,7 @@ def autoResConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
task replaceEllipsis {
|
task replaceEllipsis {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Process strings for ellipsis characters.'
|
description 'Process strings for ellipsis characters.'
|
||||||
doLast {
|
doLast {
|
||||||
allStringsResourceFiles { f ->
|
allStringsResourceFiles { f ->
|
||||||
|
@ -45,7 +45,7 @@ task replaceEllipsis {
|
||||||
}
|
}
|
||||||
|
|
||||||
task cleanApostropheErrors {
|
task cleanApostropheErrors {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Fix transifex apostrophe string errors.'
|
description 'Fix transifex apostrophe string errors.'
|
||||||
doLast {
|
doLast {
|
||||||
allStringsResourceFiles { f ->
|
allStringsResourceFiles { f ->
|
||||||
|
@ -60,7 +60,7 @@ task cleanApostropheErrors {
|
||||||
}
|
}
|
||||||
|
|
||||||
task excludeNonTranslatables {
|
task excludeNonTranslatables {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Remove strings that are marked "translatable"="false" or are ExtraTranslations.'
|
description 'Remove strings that are marked "translatable"="false" or are ExtraTranslations.'
|
||||||
doLast {
|
doLast {
|
||||||
def englishFile = file('src/main/res/values/strings.xml')
|
def englishFile = file('src/main/res/values/strings.xml')
|
||||||
|
@ -115,13 +115,13 @@ task excludeNonTranslatables {
|
||||||
}
|
}
|
||||||
|
|
||||||
task postTranslateQa {
|
task postTranslateQa {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists.'
|
description 'Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists.'
|
||||||
dependsOn ':qa'
|
dependsOn ':qa'
|
||||||
}
|
}
|
||||||
|
|
||||||
task postTranslateIpFetch {
|
task resolveStaticIps {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Fetches static IPs for core hosts and writes them to static-ips.gradle'
|
description 'Fetches static IPs for core hosts and writes them to static-ips.gradle'
|
||||||
doLast {
|
doLast {
|
||||||
def staticIpResolver = new StaticIpResolver()
|
def staticIpResolver = new StaticIpResolver()
|
||||||
|
@ -140,8 +140,8 @@ task postTranslateIpFetch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
task translateQa {
|
task updateStaticFilesAndQa {
|
||||||
group 'Translate'
|
group 'Static Files'
|
||||||
description 'Post-process for ellipsis, apostrophes and non-translatables.'
|
description 'Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release.'
|
||||||
dependsOn replaceEllipsis, cleanApostropheErrors, excludeNonTranslatables, postTranslateIpFetch, postTranslateQa
|
dependsOn replaceEllipsis, cleanApostropheErrors, excludeNonTranslatables, resolveStaticIps, postTranslateQa
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ ktlint {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(gradleApi())
|
||||||
|
|
||||||
implementation(libs.dnsjava)
|
implementation(libs.dnsjava)
|
||||||
testImplementation(testLibs.junit.junit)
|
testImplementation(testLibs.junit.junit)
|
||||||
testImplementation(testLibs.mockk)
|
testImplementation(testLibs.mockk)
|
||||||
|
|
Loading…
Add table
Reference in a new issue